Cory LaNou
Wed, 03 Feb 2021

Leveraging the Go Type System

Overview

If you haven't worked in a typed language before, it may not be obvious at first the power that it brings. This article will show you how to leverage the type system to make your code easier to use and more reusable.

If you haven't worked in a typed language before, it may not be obvious at first the power that it brings. This article will show you how to leverage the type system to make your code easier to use and more reusable.

Target Audience

This article is aimed at developers that are new to Go and have little to no Go experience.

The Problem

For this article, we will look at how to handle categorical data. In this case, specifically how to handle the genre category for classifying a book.

To start, we'll define a data structure for a Book, in which we'll want to categorize it via the genre:

package books

type Book struct {
	ID    int
	Name  string
	Genre string
}

Now that we have the book defined, let's go ahead and define some constants for genre:

const (
	Adventure     = "Adventure"
	Comic         = "Comic"
	Crime         = "Crime"
	Fiction       = "Fiction"
	Fantasy       = "Fantasy"
	Historical    = "Historical"
	Horror        = "Horror"
	Magic         = "Magic"
	Mystery       = "Mystery"
	Philosophical = "Philosophical"
	Political     = "Political"
	Romance       = "Romance"
	Science       = "Science"
	Superhero     = "Superhero"
	Thriller      = "Thriller"
	Western       = "Western"
)

So far, this seems fine. However, the genre constants are strings. While this makes for a very "humanized" way of reading the code, it's not very efficient as it pertains to a computer program. Strings will take up more storage space, and more memory in the program (not to mention if we stored millions of data records to a database). As such, we really want to use a smaller data type to represent this data.

In Go, one way we can do this is to create constants that are based on the int type.

const (
	Adventure     = 1
	Comic         = 2
	Crime         = 3
	Fiction       = 4
	Fantasy       = 5
	Historical    = 6
	Horror        = 7
	Magic         = 8
	Mystery       = 9
	Philosophical = 10
	Political     = 11
	Romance       = 12
	Science       = 13
	Superhero     = 14
	Thriller      = 15
	Western       = 16
)

We also need to change the Book structure to now represent Genre as an int:

type Book struct {
	ID    int
	Name  string
	Genre int
}

While we now have a more effecient memory model for Genre, it's not as "human" friendly. If I print out the value of a Book, we now just get an integer value. To show this, we'll write a quick test showing the output:

package books

import (
	"testing"
)

func TestGenre(t *testing.T) {
	b := Book{
		ID:    1,
		Name:  "All About Go",
		Genre: Magic,
	}

	t.Logf("%+v\n", b)

	if got, exp := b.Genre, 8; got != exp {
		t.Errorf("unexpected genre.  got %d, exp %d", got, exp)
	}
}

And here is the output.

$ go test -v ./...

=== RUN   TestGenre
    books_test.go:15: {ID:1 Name:All About Go Genre:8}
--- PASS: TestGenre (0.00s)
PASS
ok  	github.com/gopherguides/corp/.	(cached)

--------------------------------------------------------------------------------
Go Version: go1.22.0

Notice that the Genre just shows a value of 8. Any time we debug the code, or write a report, etc, we now need to figure out what 8 actually represents for a human being.

To do this, we can write a helper function that takes the Genre value, and determines what the "human" representation should be:

func GenreToString(i int) string {
	switch i {
	case 1:
		return "Adventure"
	case 2:
		return "Comic"
	case 3:
		return "Crime"
	case 4:
		return "Fiction"
	case 5:
		return "Fantasy"
	case 6:
		return "Historical"
	case 7:
		return "Horror"
	case 8:
		return "Magic"
	case 9:
		return "Mystery"
	case 10:
		return "Philosophical"
	case 11:
		return "Political"
	case 12:
		return "Romance"
	case 13:
		return "Science"
	case 14:
		return "Superhero"
	case 15:
		return "Thriller"
	case 16:
		return "Western"
	default:
		return ""
	}
}

A Better Way

While all the above code works fine, it's really missing some key points.

  • If a value for a Genre has to change in the future, we not only have to change the constant value, but we also have to update the GenreToString function. If we don't, this will create a bug in our code.
  • We aren't leveraging the type system to encapsulate this behavior for Genre. We'll show you what we mean by that shortly.

The first thing we really need to do is write a more resilient GenreToString function. What we mean by resilient is that even if the value of the Genre constant changes in the future, the GenreToString function will not need to change.

The correct way to do that is no longer use hard coded values, but use the value of the constant themselves:

func GenreToString(i int) string {
	switch i {
	case Adventure:
		return "Adventure"
	case Comic:
		return "Comic"
	case Crime:
		return "Crime"
	case Fiction:
		return "Fiction"
	case Fantasy:
		return "Fantasy"
	case Historical:
		return "Historical"
	case Horror:
		return "Horror"
	case Magic:
		return "Magic"
	case Mystery:
		return "Mystery"
	case Philosophical:
		return "Philosophical"
	case Political:
		return "Political"
	case Romance:
		return "Romance"
	case Science:
		return "Science"
	case Superhero:
		return "Superhero"
	case Thriller:
		return "Thriller"
	case Western:
		return "Western"
	default:
		return ""
	}
}

Ok, that's much cleaner (and readable), but we still haven't solved the fact that when we print it out, it shows a data value (int), and not a "human" readable value.

Types to the Rescue

Instead of using a generic int type for Genre, we can create our own type based on an existing type. In this case, we'll create a new type called Genre based on the int type:

type Genre int

Now, we'll define our constants as Genre types:

const (
	Adventure     Genre = 1
	Comic         Genre = 2
	Crime         Genre = 3
	Fiction       Genre = 4
	Fantasy       Genre = 5
	Historical    Genre = 6
	Horror        Genre = 7
	Magic         Genre = 8
	Mystery       Genre = 9
	Philosophical Genre = 10
	Political     Genre = 11
	Romance       Genre = 12
	Science       Genre = 13
	Superhero     Genre = 14
	Thriller      Genre = 15
	Western       Genre = 16
)

So far, the code doesn't really feel different. However, now that Genre is it's own type, we can add methods to it. This allows us to encapsulate the "human" behavior we want to the type, and not as a generic function.

To do this, we'll add a String method to the Genre type:

func (g Genre) String() string {
	switch g {
	case Adventure:
		return "Adventure"
	case Comic:
		return "Comic"
	case Crime:
		return "Crime"
	case Fiction:
		return "Fiction"
	case Fantasy:
		return "Fantasy"
	case Historical:
		return "Historical"
	case Horror:
		return "Horror"
	case Magic:
		return "Magic"
	case Mystery:
		return "Mystery"
	case Philosophical:
		return "Philosophical"
	case Political:
		return "Political"
	case Romance:
		return "Romance"
	case Science:
		return "Science"
	case Superhero:
		return "Superhero"
	case Thriller:
		return "Thriller"
	case Western:
		return "Western"
	default:
		return ""
	}
}

Now, we'll be able to use theString method when we want to see what the "human" value of a Genre is:

b := Book{
	ID:    1,
	Name:  "All About Go",
	Genre: Magic,
}
fmt.Println(b.Genre.String())

Output:

Magic

Magic Formatting

In Go, if you add a String method to any type, the fmt package will now use your String method to "pretty print" the representation of your type. Because of this, we will now see that if we print out the book in our tests, we get a "human-readable" Genre as well:

func TestGenre(t *testing.T) {
	b := Book{
		ID:    1,
		Name:  "All About Go",
		Genre: Magic,
	}

	t.Logf("%+v\n", b)

	if got, exp := b.Genre.String(), "Magic"; got != exp {
		t.Errorf("unexpected genre.  got %q, exp %q", got, exp)
	}
}

Output:

$ go test -v ./...

=== RUN   TestGenre
    books_test.go:16: {ID:1 Name:All About Go Genre:Magic}
--- PASS: TestGenre (0.00s)
=== RUN   ExampleGenre_String
--- PASS: ExampleGenre_String (0.00s)
PASS
ok  	book	(cached)

--------------------------------------------------------------------------------
Go Version: go1.22.0

We now see the value for Genre in the printed output is Magic, and not 8. It's also important to note that our test actually didn't change, only the way in which we leveraged our new type for Genre.

What about Iota?

For those of you that are familiar with Go already, you might have looked at this problem and asked "Why didn't you just use iota?". Iota is an identifier that you can use in Go to also create incrementing number constants. While there are several reasons I didn't use iota here, I did dedicate an entire article to the topic. Read all about it in Where and When to use Iota in Go.

Summary

While this example was purposefully basic in nature, it illustrates the power of defining your own type, and leveraging the type system in Go to create more resilient, readable, and reusable code.

Want More?

Check out our previous article, Embracing the Go Type System and learn how to use the type system to avoid common mistakes in Go.

More Articles

Hype Quick Start Guide

Overview

This article covers the basics of quickly writing a technical article using Hype.

Learn more

Writing Technical Articles using Hype

Overview

Creating technical articles can be painful when they include code samples and output from running programs. Hype makes this easy to not only create those articles, but ensure that all included content for the code, etc stays up to date. In this article, we will show how to set up Hype locally, create hooks for live reloading and compiling of your documents, as well as show how to dynamically include code and output directly to your documents.

Learn more

Go (golang) Slog Package

Overview

In Go (golang) release 1.21, the slog package will be added to the standard library. It includes many useful features such as structured logging as well as level logging. In this article, we will talk about the history of logging in Go, the challenges faced, and how the new slog package will help address those challenges.

Learn more