Skip to main content
  1. Posts/

Are You Sure You Want An Enum?

·4 mins·

Cooking with Enums #

I’ll pick on Go, but this applies to most languages without sum types. Go doesn’t have them. It doesn’t have enum types either. If you search for enum in go, you’ll probably find something like this:

type Flag int

const (
	ReadOnly Flag = 1 << iota
	NonBlock
	Append
)

Here we’re trying to model a set of flags similar to the open syscall. You can imagine this being used in a function:

func Open(name string, flags Flag) {
	isSet := func(f Flag) bool { return flags&f == f }
	fmt.Println("ReadOnly : ", isSet(ReadOnly))
	fmt.Println("NonBlock : ", isSet(NonBlock))
	fmt.Println("Append   : ", isSet(Append))
}

Does this have problems? Sure, but I find them rather pedantic. First, you can pass an uninitialized, zero-valued Flag to open:

func main() {
	var flags Flag
	// ...
	Open("somefile", flags)
}

Given this API and assumed usage, however, it’s likely that we’re going to be specifying flags inline:

	Open("somefile", ReadOnly|Append)

Or at the very least, any flag variables are probably going to be initialized directly and close to the invocation of Open, so this type of mistake isn’t a big concern. Furthermore, the uninitialized value has a valid meaning (“no flags”). Like the syscall, Open can probably be assumed to validate the set of flags and ensure that any required modes are set. Passing no flags will fail fast.

Boiling the Pot #

So that mostly seems fine. Here’s another example:

type Day int

const (
	Mon Day = iota + 1
	Tue
	Wed
	Thr
	Fri
	Sat
	Sun
)

func Schedule(d Day) {
   // ...
}

Hmm, this seems more dubious. The zero value has no valid meaning; we must choose to either have it correspond to a valid day, or leave it undefined (either implicitly as above, or with an explicit None variable). Neither option is attractive.

Furthermore, days of the week seem like something we’re more likely to get back as a struct member or function return value. It’s much less obvious that Schedule or other code expecting a day will perform validation of the kind that made Open safe.

Despite the above, you’ll see this pattern all the time. How big of an issue it is just depends on context.

Fire in the Kitchen #

Here’s another example:

type AccountId int
type AccountType int

const (
	Checking AccountType = iota + 1
	Savings
)

func DebitAccount(id AccountId, a AccountType) {
	switch a {
	case Checking:
		// ...
	case Savings:
		// ...
	default:
		panic("invalid account type")
	}
}

Now we have a full-on grease fire in the kitchen. If you see code switching on enum types like this, ensure your sprinklers and extinguishers have been tested recently.

We have all the problems from before with a new, worse one. The intention is clearly that DebitAccount be able to handle each account type. Enums are a terrible fit for this in any language, but especially in Go.

OSHA Compliance #

What’s the solution? Well, what do we want?

  1. A fixed set of types on which we will add operations.
  2. Guarantees that our code handles all cases if new types are added.

Y’know what that sounds like? Our old friend double dispatch, via the Visitor Pattern:

type AccountVisitor interface {
	VisitCheckingAccount(*CheckingAccount)
	VisitSavingsAccount(*SavingsAccount)
}

type Account interface {
	Accept(AccountVisitor)
}

type CheckingAccount struct{}

func (c *CheckingAccount) Accept(v AccountVisitor) {
	v.VisitCheckingAccount(c)
}

type SavingsAccount struct{}

func (s *SavingsAccount) Accept(v AccountVisitor) {
	v.VisitSavingsAccount(s)
}

type DebitVisitor struct{}

func (d *DebitVisitor) VisitCheckingAccount(c *CheckingAccount) {
	fmt.Println("Debit Checking")
}

func (d *DebitVisitor) VisitSavingsAccount(s *SavingsAccount) {
	fmt.Println("Debit Savings")
}

func (d *DebitVisitor) Debit(a Account) {
	a.Accept(d)
}

func main() {
	checking := &CheckingAccount{}
	savings := &SavingsAccount{}
	debitor := &DebitVisitor{}
	debitor.Debit(checking)
	debitor.Debit(savings)
}

I swear this must be the most under-utilized pattern in programming. Don’t throw this particular baby away with the OOP bathwater. For Go in particular, given its lack of enum types, I find it especially useful even for simple cases.