Cory LaNou
Wed, 03 Feb 2021

Leveraging the Go Type System

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:14: {ID:1 Name:All About Go Genre:8}
--- PASS: TestGenre (0.00s)
PASS
ok      github.com/gopherguides/corp/_blog/types/leveraging-types/src/v2     (cached)

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 the String 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 -count=1 .
=== RUN   TestGenre
    books_test.go:16: {ID:1 Name:All About Go Genre:Magic}
--- PASS: TestGenre (0.00s)
PASS
ok      book    0.059s

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

Exploring "io/fs" to Improve Test Performance and Testability

The most anticipated feature of Go 1.16 is the addition to the Go tooling, and standard library, that allow for embedding static content into binaries. While it is tempting to start playing with this new toy right away, it is important to understand how it works first. In this article we are going to take a look at the new io/fs package introduced in Go 1.16 to support embedding.

Learn more

Where and When to use Iota in Go

Iota is a useful concept for creating incrementing constants in Go. However, there are several areas where iota may not be appropriate to use. This article will cover several different ways in which you can use iota, and tips on where to be cautious with it's use.

Learn more

Embracing the Go Type System

Go is a typed language, but most Go developers don't truly embrace it. This short article talks about tips and tricks to write more robust code using custom types in Go.

Learn more

Subscribe to our newsletter