Pros and cons of leveraging Go types

UPDATE:

Since writing these over 3 years ago I have since learned a lot more about GoLang and its type system. Ironically, I now realize that GoLang has type aliases that are effectively what I was lobbying for.

ORIGINAL POST:

Go is a great language — probably one of the best — but it is not without its warts. Let me illustrate one aspect of Go that is really useful, but also one of the issues that using that feature causes (aka an experience report.)

Pro

One of the many great features in Go is its ability to declare a type to be derived from another type. That can allow developers to really fine-tune their code and make their code’s logic easier to reason about.

For example, instead of using a string for a hostname, we can define a type Hostname which then allows us to be very specific about what parameters are accepted by selected methods.

Compare this example that uses string:

type Api struct {
   Hostname string
}
func NewApi(hostname string) *Api {
   return &Api{
      Hostname: hostname,
   }
}
func main() {
   api := NewApi("api.gearbox.works")
   fmt.Printf("Our API is located at: https://%s", api.Hostname)
}

With this example that defines and uses the type Hostname:

type Hostname string
type Api struct {
   Hostname Hostname
}
func NewApi(hostname Hostname) *Api {
   return &Api{
      Hostname: hostname,
   }
}
func main() {
   api := NewApi("api.gearbox.works")
   fmt.Printf("Our API is located at: https://%s", api.Hostname)
}

Not much apparent difference, right? But that is only because we have a trivial example. Let’s consider adding a simple Config struct. Our first example will use type string. Do you see the problem? (And yes, this is a contrived example):

type Config struct {
    ApiUrl string
}
func NewConfig() *Config {
    return &Config{}
}
func main() {
    config := NewConfig()
    config.ApiUrl = "https://api.gearbox.works"
    api := NewApi(config.ApiUrl)
    fmt.Printf("Our API is located at: https://%s", api.Hostname)
}

If the problem is not immediately obvious, we passed a URL as an argument to our construtor (e.g. config.ApiUrl) when the constructor expected a raw domain. But the compiler did not complain because both types were string and so it would become a bug to discover at runtime.

Consider instead the following:

type Url string
type Config struct {
    ApiUrl Url
}
func NewConfig() *Config {
    return &Config{}
}
type Hostname string
type Api struct {
   Hostname Hostname
}
func NewApi(hostname Hostname) *Api {
   return &Api{
      Hostname: hostname,
   }
}
func main() {
    config := NewConfig()
    config.ApiUrl = "https://api.gearbox.works"
    api := NewApi(config.ApiUrl) // <=== GENERATES COMPILE ERROR!!!                    
    fmt.Printf("Our API is located at: https://%s", api.Hostname)
}

Now we get a compiler error, not a runtime error! And if you are using JetBrain’s GoLand, the IDE will tell you when you are coding that the types are not aligned.

Con

Typing of the nature I just illustrated can make for a lot more robust and easier to reason about codebase.

But using this feature can also make coding in Go a lot more tedious. Because of this I expect more often than not package developers avoid this feature to make thing simpler and easier for those that consume their package, and project programmers avoid it to minimize apparent work.

How is it more tedious? Let me give you a func signature from a project I am working on:

GetItem(ctx apimodeler.Context, hostname apimodeler.ItemId) (list apimodeler.ApiItemer, sts status.Status) 

Let’s compare that to what might be easier to read:

GetItem(ctx Context, hostname ItemId) (list ApiItemer, sts Status) 

Hopefully you agree the latter is easier to reason about?

Go actually does include a feature to make approach possible — called dot imports — except there is an effectively-accepted proposal to remove it from Go.

Even so, dot imports are not perfect because they do not have a conflict resolution mechanism between different packages.

What I think would make things nicer would be type aliases; type names that would be considered compatible from the compiler’s perspective.

Consider a proposed type alias syntax that would allow the following declarations, which relates to my prior two (2) code examples:

type Context alias apimodeler.Context
type ItemId alias apimodeler.ItemId
type ApiItemer alias apimodeler.ApiItemer
type Status alias status.Status

Summary

So, if we had the above type aliases, we could greatly simplify Go code that — for example — implements a large interface such as a driver. Then we could simplify Go code that makes large use of other packages.