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 theGenreToString
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
Quick Tips: Pointer Optimizations in Go
Overview
This article explores important performance considerations when working with pointers in Go. We'll cover key topics like returning pointers to local variables, choosing between pointer and value receivers for methods, and how to properly measure and optimize pointer-related performance using Go's built-in tools. Whether you're new to Go or an experienced developer, these tips will help you write more efficient and maintainable code.
Hype Quick Start Guide
Overview
This article covers the basics of quickly writing a technical article using Hype.
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.