I've always had some gripes with how enums
are implemented in Go. They are not treated as first class citizens and are not fully type safe either. This could potentially lead to bugs that are undetectable by the compiler.
To demonstrate this, let's create an enum in the most Go idiomatic way possible by using type aliasing and iota
for incremental constants.
type Direction int
const (
North Direction = iota
South
East
West
)
Here, we basically define a set of constants with the shared type Direction
to enumerate the four directions. However, because the Direction
type itself is an alias for int
, one can easily create new unintended directions by creating variables of type Direction
with new integer values. This not only breaks the invariant of enums
being a fixed list of values but also leads to a loss in type safety at compile time.
Here's an example:
func (d Direction) String() string {
switch d {
case North:
return "North"
case South:
return "South"
case East:
return "East"
case West:
return "West"
default:
return "Invalid"
}
}
func main() {
direction := West
fmt.Println("Direction is", direction)
//Output: Direction is West
var Midwest Direction = 10
fmt.Println("Direction is", Midwest)
//Output: Direction is Invalid
}
In this code bite, we are able to create a new invalid Direction
called Midwest which is outside the intended list of directions. We are also able to use it with the String
implementation on Direction
. While we do end up getting 'invalid' as output for printing the string representation for Midwest
at runtime, it would've been even better if this was caught at compile time itself!
The compiler assumes that any int
can be a Direction
and thereby any method implementation for Direction
will have to be able to work with any int
value. This can have unintended side effects at runtime! Compile-time type safety is important here.
Generics
One way to enable full type safety is to make Direction
a generic type!
We use the same example as above but implemented using generics.
First, we define each direction as its own type.
// Define the enum values as types
// TODO: Difference between struct{} and interface{}
type (
North struct{}
South struct{}
East struct{}
West struct{}
)
Then we create a generic type that takes a type parameter T
constrained by the above type set.
// Set a type constraint
type Direction[T North | South | East | West] struct {
Name T
}
We can now define any function on Direction[T]
func (d Direction[T]) String() string {
switch any(d.Name).(type) {
case North:
return "North"
case South:
return "South"
case East:
return "East"
case West:
return "West"
default:
return "Invalid"
}
}
This function will only accept Direction
with a valid type parameter T
from North | South | East | West
.
In fact, we will not even be able to create a new invalid Direction
. So if we run the below code, it will not compile!
type Midwest struct{}
func main() {
direction := Direction[West]{}
fmt.Println("Direction is", direction)
// Output: Direction is West
invalidDirection := Direction[Midwest]{}
// Compile time error: Midwest does not satisfy North | South | East | West (Midwest missing in main.North | main.South | main.East | main.West)
}
And voilà! We've created fully type safe enums
with generics! Play with the code here
Bonus: Here's another approach to enums
with generics.
I went with the first approach because it seemed closer to the usual idiomatic way and slightly more readable.
References
- https://go.dev/blog/intro-generics
- https://dev.to/ankitmalikg/implementing-enums-in-golang-40ie