Type Safe Enums in Go
I’m willing to bet that many Go programmers have seen or used this strategy for enumerating things in code before:
type Profession int
const (
Unknown Profession = iota
Warrior
Cleric
Hunter
Mage
)
In practice, this is probably fine. In fact, I can’t think of a time this has caused a (known) bug in one of my programs (yet.) Famous last words.
However, there are times where I wish the solution was a little more bulletproof at compile-time.
For example, I dislike having an Unknown variant. This is mainly to avoid ambiguity when client code introduces a zero-value for this enum and perhaps forgets to set it, like so:
var prof Profession
_ = whatIsYourProfession(prof)
This means that most code that cares about the enum type will have to account for the Unknown variant, like so:
func whatIsYourProfession(p Profession) error {
switch p {
case Unknown:
return errors.New("unknown profession")
case Warrior:
fmt.Println("Couldn't beat you in an arm-wrestling match.")
case Cleric:
fmt.Println("Divine!")
case Hunter:
fmt.Println("Shoot an arrow over them mountains.")
case Mage:
fmt.Println("I'll pick a card, any card.")
}
return nil
}
Even though I accounted for all of the professions, there’s still a bug here.
Technically, client code could do this:
if err := whatIsYourProfession(Profession(-1)); err == nil {
fmt.Println("fooled ya!")
}
Again, in practice, it’s unlikely that client code would pull the pin on that grenade and hand it to us, but I wish the compiler would stop them from doing so.
So I guess we could add a default case:
func whatIsYourProfession(p Profession) error {
switch p {
case Unknown:
return errors.New("unknown profession")
case Warrior:
fmt.Println("Couldn't beat you in an arm-wrestling match.")
case Cleric:
fmt.Println("Divine!")
case Hunter:
fmt.Println("Shoot an arrow over them mountains.")
case Mage:
fmt.Println("I'll pick a card, any card.")
default:
return errors.New("how could you betray me like this?")
}
return nil
}
We could prevent the zero-value situation by sealing the type within our package so client code can’t zero-value construct it without using specific constructor functions:
package profession
import (
"errors"
)
var (
ErrUnknown error = errors.New("unknown profession")
)
type Profession func() profession
func (p Profession) String() string {
var s string
switch {
case Is(p, Warrior):
s = "warrior"
case Is(p, Cleric):
s = "cleric"
case Is(p, Hunter):
s = "hunter"
case Is(p, Mage):
s = "mage"
}
return s
}
type profession int
const (
warrior profession = iota
cleric
hunter
mage
)
func FromString(s string) (Profession, error) {
switch s {
case "warrior":
return Warrior, nil
case "cleric":
return Cleric, nil
case "hunter":
return Hunter, nil
case "mage":
return Mage, nil
}
return nil, ErrUnknown
}
func Warrior() profession {
return warrior
}
func Cleric() profession {
return cleric
}
func Hunter() profession {
return hunter
}
func Mage() profession {
return mage
}
func Is(r, target Profession) bool {
return r() == target()
}
Now there’s no such thing as an unknown zero-value profession. Furthermore, because the underlying type is sealed in the package and only accessible via constructor functions, the worst thing client code can do is pass a nil function pointer through if they create a zero-value (nil pointer) profession.Profession:
var prof profession.Profession // nil
Whether or not that offends you, is up to you. Representing an invalid state is a bug, and I’d rather segfault than limp along and hope for the best despite garbage state flowing through my program.
Let’s tie it all together with a complete example program, where we can now have peace of mind that garbage enum values won’t be able to quietly sneak through.
package main
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"codeofconnor.com/profession/profession"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
prof, err := profession.FromString(line)
if errors.Is(err, profession.ErrUnknown) {
fmt.Println("I've never heard of a", line)
continue
}
whatIsYourProfession(prof)
}
}
func whatIsYourProfession(prof profession.Profession) {
var message string
switch {
case profession.Is(prof, profession.Warrior):
message = "Couldn't beat you in an arm-wrestling match."
case profession.Is(prof, profession.Cleric):
message = "Divine!"
case profession.Is(prof, profession.Hunter):
message = "Shoot an arrow over them mountains."
case profession.Is(prof, profession.Mage):
message = "I'll pick a card, any card."
default:
message = "I've never met a " + prof.String() + " before."
}
fmt.Println(message)
}
% ./p
warrior
Couldn't beat you in an arm-wrestling match.
hunter
Shoot an arrow over them mountains.
mage
I'll pick a card, any card.
cleric
Divine!
potato
I've never heard of a potato