Q: Can I have a fully-functioning enum in Go, similar to XYZ (my other programming language of choice)?
A: No. Proper enums do not exist in Go, but you can come pretty close if you pull a few tricks.
One of Go’s most requested features, after generic type parameters and more ergonomic error handling, has been the addition of enums. And to those who would point out that Go already has enums, I’d say no, the iota hack is far from sufficient.
const (
Motorcycle = iota
Car
Bus
Truck
)
It’s so easy to mess up by comparing any of the above with a random integer (or any other primitive value) that I don’t even want to talk about it.
Someone could say, well, create a new type based on a primitive and then use that type everywhere.
type Vehicle int
const (
Motorcycle Vehicle = iota
Car
Bus
Truck
)
That’s one idea better. Now, we can at least make sure that people put some reasoning when working with our “enum”:
func calculateInsuranceRate(v Vehicle) float64 {
// ...
}
However, what could stop one from calling the function like this:
rate := calculateInsuranceRate(Vehicle(42)) // Ouch!
What is even worse: what happens if 42 becomes a valid value one day? Our code might end up working in mysterious ways without us even knowing about it or why.
A safer, more robust enum, based on interfaces #
An enum has one distinct property - it has a finite number of values, whereas new ones get accepted only if they are a part of the enum. In addition, if you look at enums in other programming languages (e.g, Java, Rust, Swift, Kotlin, etc.), they are far more comprehensive than simply a scalar value that one compares against. Taken to the extreme, an enum is a form of finite-state machine where each value encapsulates its own state and behavior. I guess, you know where I am going - interfaces.
Let’s create a separate package reserved explicitly for our enum. Let’s call it vehicle
. Inside vehicle/vehicle.go
, add the following interface definition:
type Type interface {
isEnumValue()
}
This is an exported interface, which can be implemented only from inside the package. This way, we can somewhat prevent the creation of new enum values (but not eliminate it entirely, read on).
Next to our interface, we can declare four structs, representing all the enum options:
type car struct {
// potentially, add some fields here to keep some state
}
type motorcycle struct {
// potentially, add some fields here to keep some state
}
type bus struct {
// potentially, add some fields here to keep some state
}
type truck struct {
// potentially, add some fields here to keep some state
}
func (_ *motorcycle) isEnumValue() {}
func (_ *car) isEnumValue() {}
func (_ *bus) isEnumValue() {}
func (_ *truck) isEnumValue() {}
Notice that those are also un-exported, so that no outside code can instantiate them at will.
To make those publicly accessible, all we have to do is declare a struct at the package level that exposes them:
var (
Values = struct {
Motorcycle *motorcycle
Car *car
Bus *bus
Truck *truck
}{
Motorcycle: &motorcycle{},
Car: &car{},
Bus: &bus{},
Truck: &truck{},
}
)
And there you have it! All an outside package would see now is that there is a public interface called vehicle.Type
, but it cannot be implemented, and the only way to provide a working implementation of it is to use one of the vehicle.Values
:
func calculateInsuranceRate(v vehicle.Type) (float64, error) {
switch v {
case vehicle.Values.Motorcycle:
return 0.05, nil
case vehicle.Values.Car:
return 0.2, nil
case vehicle.Values.Bus:
return 0.3, nil
case vehicle.Values.Truck:
// We can even invoke some methods on the concrete value
return 0.3 * vehicle.Values.Truck.FetchSomeData(), nil
default:
return 0, errors.New("vehicle type undefined")
}
}
That’s quite a lot of boilerplate, I know. The upside of that is that you only need to pay the price of it once, and remember to add new values accordingly. Or, you can use a code generator to do it for you. And you can add all kinds of methods to each of your value types so that you can convert from and back to scalar values (e.g. for serialisation purposes), or do all kinds of stuff.
But beware … #
Now for the caveat. Some of you might be asking themselves, well, why don’t we add those methods directly to the vehicle.Type
interface. It will save us quite some boilerplate with all that checking - you could simply do:
return v.CalculateInsuranceRate()
and be done with it, right?
Well, yes, but that also opens the door for potential misuse of the enum. If you recall one of my previous blog posts, you will remember that you can make a Go type pass for an interface, without really implementing it. Unfortunately, that applies to the un-exported methods too. Thus, the following piece of code is perfectly valid:
type FakeVehicle {
vehicle.Type
}
rate, err := calculateInsuranceRate(&FakeVehicle{})
which is why I’d prefer having the interface as small as possible, and use concrete methods or package level functions that guard against mis-use.
What do you think? Will you ever use this construct in your Go code? Feel free to let me know in the comments below.
Inspiration #
Have something to say? Join the discussion below 👇
Want to explore instead? Fly with the time capsule 🛸
You may also find these interesting
Interfaces Are Not Meant for That
It’s time to ask ourselves how much abstraction in our Go code really makes sense.
Python is Easy. Go is Simple. Simple != Easy.
Python and Go have distinct qualities that can complement each other.
Focus on the Happy Path With Step Functions
A simple pattern that will help you reduce error handling, while keeping your Go code simple and idiomatic.