Safer Enums in Go
Enums are a crucial part of web applications. Go doesn’t support them out of the box, but there are ways to emulate them.
Many obvious solutions are far from ideal. Here are some ideas we use that make enums safer by design.
iota
Go lets you enumerate things with iota
.
const (
Guest = iota
Member
Moderator
Admin
)
While Go is explicit, iota
seems relatively obscure.
If you sort the const
group any other way, you introduce side effects.
In the example above, you could accidentally create moderators instead of members.
You can explicitly assign a number to each value to avoid this issue, but it makes iota
obsolete.
iota
works well for flags represented by powers of two.
const (
Guest = 1 << iota // 1
Member // 2
Moderator // 4
Admin // 8
)
// ...
user.Roles = Member | Moderator // 6
Bitmasks are efficient and sometimes helpful. However, it’s a different use case than enums in most web applications. Often, you’ll be fine storing all roles in a list. It’ll also be more readable.
The main issue with iota
is it works on integers that don’t guard against passing invalid values.
func CreateUser(role int) error {
fmt.Println("Creating user with role", role)
return nil
}
func main() {
err := CreateUser(-1)
if err != nil {
fmt.Println(err)
}
err = CreateUser(42)
if err != nil {
fmt.Println(err)
}
}
CreateUser
will happily accept -1
or 42
even though there are no corresponding roles.
Of course, we could validate this within the function. But we use a language with strong types, so let’s take advantage of it. In our application’s context, a user role is much more than a vague number.
❌ Anti-pattern: Integer enums
Don’t use iota
-based integers to represent enums that are not sequential numbers or flags.
We could introduce a type to improve the solution.
type Role uint
const (
Guest Role = iota
Member
Moderator
Admin
)
It looks better, but it’s still possible to pass any arbitrary integer in place of Role
. The Go compiler doesn’t help us here.
func CreateUser(role Role) error {
fmt.Println("Creating user with role", role)
return nil
}
func main () {
err := CreateUser(0)
if err != nil {
fmt.Println(err)
}
err = CreateUser(role.Role(42))
if err != nil {
fmt.Println(err)
}
}
The type is an improvement over a bare integer, but it’s still an illusion. It doesn’t give us any guarantee that the role is valid.
Sentinel values
Because iota
starts from 0, the Guest
is also the Role
’s zero-value.
It makes it hard to detect if the role is empty or someone passed a Guest
value.
You can avoid this by counting from 1. Even better, keep an explicit sentinel value you can compare and can’t mistake for an actual role.
const (
Unknown Role = iota
Guest
Member
Moderator
Admin
)
func CreateUser(r role.Role) error {
if r == role.Unknown {
return errors.New("no role provided")
}
fmt.Println("Creating user with role", r)
return nil
}
✅ Tactic: Explicit sentinels
Keep an explicit variable for the enum’s zero-value.
Slugs
Enums seem to be about sequential integers, but it’s rarely a valid representation. In web applications, we use enums to group possible variants of some type. They don’t map well to numbers.
It’s hard to understand the context when you see a 3
in the API response, a database table, or logs.
You have to check the source or the outdated documentation to know what it’s about.
String slugs are more meaningful than integers in most scenarios. Wherever you see it, a moderator
is obvious.
Since iota
doesn’t help us anyway, we can as well use human-readable strings.
type Role string
const (
Unknown Role = ""
Guest Role = "guest"
Member Role = "member"
Moderator Role = "moderator"
Admin Role = "admin"
)
✅ Tactic: Slugs
Use string values instead of integers.
Avoid whitespace for easier parsing and logging. Use camelCase
, snake_case
, or kebab-case
.
Slug are especially useful for error codes. An error response like {"error": "user-not-found"}
is obvious in contrast to {"error": 4102}
.
However, the type can still hold any arbitrary string.
err = CreateUser("super-admin")
if err != nil {
fmt.Println(err)
}
Struct-based Enums
The final iteration uses structs. It lets us work with code secure by design. We don’t need to check if the passed value is correct.
type Role struct {
slug string
}
func (r Role) String() string {
return r.slug
}
var (
Unknown = Role{""}
Guest = Role{"guest"}
Member = Role{"member"}
Moderator = Role{"moderator"}
Admin = Role{"admin"}
)
Because the slug
field is unexported, it’s not possible to fill it from outside the package.
The only invalid role you can construct is the empty one: Role{}
.
We can add a constructor to create a valid role based on a slug:
func FromString(s string) (Role, error) {
switch s {
case Guest.slug:
return Guest, nil
case Member.slug:
return Member, nil
case Moderator.slug:
return Moderator, nil
case Admin.slug:
return Admin, nil
}
return Unknown, errors.New("unknown role: " + s)
}
✅ Tactic: Struct-based enums
Encapsulate enums in structs for extra compile-time safety.
This approach is perfect when you work with business logic. Keeping structures always in a valid state in memory makes your code easier to work with and understand. It’s enough to check if the enum type isn’t empty, and you’re sure it’s a correct value.
There’s one potential issue with this approach. Structs cannot be constants in Go, so it’s possible to overwrite the global variables like this:
roles.Guest = role.Admin
There is no sane reason to do this, though. You’re much more likely to pass an invalid integer by accident.
Another disadvantage is you have to make updates in two places: the enums list and the constructor. However, it’s easy to spot, even if you miss it at first.
How do you do it?
When there’s a missing Go feature, we tend to develop our ways of doing things. What I described is just one possible approach. Do you use another pattern to store your enums? Please share it in the comments. 🙂
For complete source code, see the anti-patterns repository.