My golang guilty pleasure: ADTs
Golang is designed as a quite open language. It’s not designed for you to express a lot of constraints on types. It doesn’t have the facilities for doing so. A notable omission from the language is enums. While enum support might come at some point, ADT support seems highly unlikely.
If you haven’t heard of ADTs, they are similar to enums but apply to types instead. The ADT abbreviation stands for Algebraic Data Type, but I won’t explain that mouthful today.
Rust has this concept but just calls it enum and there’s an example from the standard library in the official documentation:
enum Option<T> {
None,
Some(T),
}
It means that whenever you have a variable of type `Option`, it’s guaranteed to be a `None` or a `Some` value. You can’t create other variants elsewhere in the code. If you wish, somewhere where you use it, to break it down into the possible cases, you can rely on the compiler to force you to handle all possibilities. This is usually referred to as exhaustiveness checking.
But this is far from a Rust-specific feature. You can find direct support for it or an equivalently powerful construct in a lot of other programming languages.
The golang codebase where I’ve worked the most lately is a compiler and compilers are a domain where this kind of closed universe domain modelling comes up often.
Even though solutions like go-check-sumtype exist, I’ve settled for a slightly different flavour. Instead of additional tooling, I rely on, or perhaps abuse, language features to achieve it.
Let’s start with a sample Rust enum, taken from Rust by Example:
enum WebEvent {
PageLoad,
Paste(String),
Click { x: i64, y: i64 },
}
And, without further ado, convert it to golang (explanation follows):
type WebEvent interface {
sealedWebEvent()
WebEventCases() (*PageLoad, *Paste, *Click)
}
type PageLoad struct{}
func (p *PageLoad) sealedWebEvent() {}
func (p *PageLoad) WebEventCases() (*PageLoad, *Paste, *Click) {
return p, nil, nil
}
type Paste struct {
Content string
}
func (p *Paste) sealedWebEvent() {}
func (p *Paste) WebEventCases() (*PageLoad, *Paste, *Click) {
return nil, p, nil
}
type Click struct {
X int
Y int
}
func (c *Click) sealedWebEvent() {}
func (c *Click) WebEventCases() (*PageLoad, *Paste, *Click) {
return nil, nil, c
}
The non-exported `sealedWebEvent` method makes it so no implementations of the interface can be defined in other packages. This is how you can enforce a closed universe of types implementing the interface.
The `WebEventCases` function is the (fisher-price grade) pattern matching. If any cases are added or removed from the return of the function, golang compiler forces us to update all call-sites where they are used (either by being returned or assigned to variables). The other key feature is that golang compiler doesn’t allow you to have unused variables within a method/function body. This means that as long as you assigned it, you’ll use it.
On top of all of that, if you decide to return within the handling of each case, the compiler will also make sure none are missed.
How all of this looks in practice:
func webEventDescription(webEvent WebEvent) string {
casePageLoad, casePaste, caseClick := webEvent.WebEventCases()
if casePageLoad != nil {
return"page load"
} else if casePaste != nil {
return "paste"
} else if caseClick != nil {
return "click"
} else {
panic("WebEventCases nil")
}
}
Don’t worry. I wouldn’t call it idiomatic code. No need to bring out the pitchforks. It’s a very handy trick, though.