Cory LaNou
Tue, 26 Jan 2021

Embracing the Go Type System

Go is a typed language, but many projects I'm asked to consult on aren't embracing the "simple" things that are really important. Today, I'm going to talk a little about embracing the Go type system as it pertains to defining your data structures.

Target Audience

This article is aimed at anyone who has worked with a typed language and has little to no Go experience.

The Problem

For this article, we'll focus on how we would design the data structures for a product review. The entities involved will be a product data structure, a user data structure, and a product review data structure.

Let's get started defining just the bare minimum for this exercise:

type Product struct {
	ID   int
	Name string
}
type User struct {
	ID       int
	Username string
}
type ProductReview struct {
	ProductID int
	UserID    int
	Review    string
}

Note: For this example, I wrote an in-memory database that simulates a real database. Since it doesn't really matter what database we are using, we aren't going to look at any of that code. We will, however, see it being used in our tests later on.

Now, let's look at what a method would look like that retrieves a product review from the database:

func Find(productID, userID int) (*ProductReview, error)

This method takes a product ID, and a user ID, and retrieves the review from that user for the specified product.

What's Wrong?

Technically, nothing is wrong with the code so far. It's all valid Go code. It will compile, and it will run.

To show this is the case, let's go ahead and write an integration test to validate our assertions.

func TestProductReview(t *testing.T) {
	// Create an instance to our database
	db := NewDatabase()

	// Create a product
	product := Product{Name: "Computer"}
	if err := db.Products.Create(&product); err != nil {
		t.Fatal(err)
	}

	// Create a user
	user := User{Username: "gopher"}
	if err := db.Users.Create(&user); err != nil {
		t.Fatal(err)
	}

	// Create a product review
	exp := &ProductReview{UserID: user.ID, ProductID: product.ID, Review: "This computer is awesome!"}
	if err := db.ProductReviews.Save(exp); err != nil {
		t.Fatal(err)
	}

	// Retrieve the product review
	got, err := db.ProductReviews.Find(user.ID, product.ID)
	if err != nil {
		t.Fatal(err)
	}

	if !cmp.Equal(got, exp) {
		t.Fatalf("unexpected product review:\n%s", cmp.Diff(got, exp))
	}

	t.Logf("%+v", got)
}

If we run the test, we can see that the test does in fact pass:

$ go test -v -run TestProductReview
=== RUN   TestProductReview
    database_test.go:41: &{ProductID:1 UserID:1 Review:This computer is awesome!}
--- PASS: TestProductReview (0.00s)
PASS
ok      store   0.064s

We also logged out the product review at the end of the test just to prove we did save and retrieve the correct product review from the database. So, where is the problem? Maybe you already caught it... but maybe you didn't.

The problem is that we swapped our method arguments when we called the Find method. If we look back at the signature, we see it's defined as:

func Find(productID, userID int) (*ProductReview, error)

However, we called it with the productID swapped with the userID (effectively sending in a product id in place of a user id, and vice versa:

db.ProductReviews.Find(user.ID, product.ID)

Ok, so we know for sure that we wrote a bad test, so why did the test pass? The reason is that, as with many integration tests, we are testing from an "empty" database. So, when we create the first user, the ID is going to be "1", and when we create the first product, it's ID is also going to be "1". And because both ID types are "int"s, we never see that we actually wrote a bad test.

So you might be thinking, ok, so we wrote a bad test, that happens. Why don't we just correct the test by sending in the method arguments in the correct order and ship the code.

We could do this, however, the same problem we just experienced in our test, can also occur in production. There is nothing stopping us from accidentally reversing the order of the arguments when we write production code. The big difference, however, is that in production, we've now written a really ugly bug that will result in adverse outcomes, likely corrupt data, and be a pain to track down and correct.

Why Are You Telling Me This?

Remember, this article is all about embracing the type system. And all too often, when I do code reviews, I see that the code does not actually embrace the type safety of the language. By making a couple of small design decisions, we can ensure that we don't create this bug in our software.

Typed IDs

The easiest way to ensure that we don't send in a Product ID by mistake when a User ID is needed, is to create a custom type for our ID's. This is what our structures will look like when we do this:

type ProductID int

type Product struct {
	ID   ProductID
	Name string
}
type UserID int

type User struct {
	ID       UserID
	Username string
}
type ProductReview struct {
	ProductID ProductID
	UserID    UserID
	Review    string
}

Now, instead of using the generic int type for ID's, each structure has it's own type for an ID.

That means we also have to update our Find method, which will now use the custom types for arguments:

func Find(productID ProductID, userID UserID) (*ProductReview, error)

Now when we the tests, I get the following error:

$ go test -v -run TestProductReview
# store [store.test]
./database_test.go:33:41: cannot use user.ID (type UserID) as type ProductID in argument to db.ProductReviews.Find
./database_test.go:33:53: cannot use product.ID (type ProductID) as type UserID in argument to db.ProductReviews.Find
FAIL    store [build failed]

And if we look at the failing line of code, we see that the arguments are indeed reversed:

got, err := db.ProductReviews.Find(user.ID, product.ID)

This is what I mean when I talk about embracing the type system in Go. If you properly architect your data types, the compiler will do a lot more work to ensure that you don't make mistakes in your tests or production code.

If we don't make the conversion, Go will enforce the type safety and we'll receive a compile time error.

Ok, got it. Always use Typed IDs

Actually... no. When designing your software, you always need to look at the pros and cons. While using Typed IDs can catch bugs, sometimes it just becomes a hassle because you have to keep converting types from something like a basic int to a ProductID

If you look at the following code, this will not work as Go will enforce type safety, and even though ProductID is based on an int , it is NOT an int , and can't be directly assigned an int value:

type ProductID int

type Product struct {
	ID   ProductID
	Name string
}

func main() {
	id := 1
	product := Product{
		ID:   id,
		Name: "Computer",
	}
	fmt.Println(product)
}

$ go run ./invalid.go
# command-line-arguments
./invalid.go:16:3: cannot use id (type int) as type ProductID in field value

To make sure you follow the type safety rules you've created, you have to tell Go you want to convert the int value of id to the type of ProductID by performing a type conversion :

ProductID(id)

This is what the code will now look like:

type ProductID int

type Product struct {
	ID   ProductID
	Name string
}

func main() {
	id := 1
	product := Product{
		ID:   ProductID(id),
		Name: "Computer",
	}
	fmt.Println(product)
}

How do I know when to use them?

There are a couple of signs you can look for that Typed IDs may benefit your code. First, if you have any functions that take more than one ID from different data types to perform operations. We saw this in our example because a ProductReview used a composite key of ProductID and UserID . This can happen in projects such as a RESTful Web API. Or maybe you are consuming data from XML or JSON and you want to ensure that all the data types are enforced when processing the data.

If your code doesn't mix ID's, then likely using Typed IDs will only result in extra code without any benefit.

Summary

As we saw in this article, even writing tests, you can still have code that passes, but is not correct. By embracing the type safety of Go, we enable the compiler to ensure that we never accidentally swap arguments for different types.

Want more?

Learn more about how the type system and constants can make your code more resuable in our article Leveraging the Go Type System .

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

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.

Learn more

Subscribe to our newsletter