Embracing the Go Type System
Overview
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.
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:
"`sh $ go test -v -run TestProductReview === RUN TestProductReview database_test.go:41: &{ProductID:1 UserID:1 Review:This computer is awesome!}
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:
```go
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
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.
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.