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