The missing enum keyword
You finish a Python project where enum.Enum handles your order states cleanly. You switch to Go and reach for the same pattern. The compiler stops you. Go does not have an enum keyword. It does not have a built-in way to declare a closed set of named values. Instead, Go gives you a constant generator called iota and leaves the rest to your type system.
This design choice feels strange at first. You are used to the language protecting you from invalid states. Go flips the script. It gives you the raw integers and expects you to wrap them in a custom type. The tradeoff is explicit control. You decide how the values behave, how they serialize, and how they fail.
Think of iota as a ticket dispenser at a busy deli. Every time you start a new const block, the machine resets to zero. Each line you declare gets the next number in sequence. You never type the numbers yourself. The compiler prints them for you during the build step. This keeps your code short and guarantees the sequence never drifts out of sync when you reorder or insert values.
Goroutines are cheap. Channels are not magic. Enums in Go are just integers wearing a custom type jacket.
How iota actually works
The generator lives inside const blocks. It resets to zero whenever the compiler sees the const keyword. It increments by one for every subsequent line in that same block. You pair it with a custom type to create a boundary around the values.
Here is the simplest pattern:
package main
// Status wraps an int to create a closed set of values.
type Status int
const (
// Unknown starts the sequence at 0.
StatusUnknown Status = iota
// Active becomes 1.
StatusActive
// Inactive becomes 2.
StatusInactive
)
func main() {
// Assign the constant to a variable.
s := StatusActive
// Print the underlying integer value.
println(s)
}
The compiler evaluates iota at build time, not at runtime. The generated integers are baked directly into the binary. There is no hidden map lookup. There is no runtime overhead. The custom type Status is just an alias for int with a different name. The Go type system treats Status and int as completely separate types. This separation is the entire safety mechanism.
You cannot accidentally compare a Status to a raw integer. The compiler rejects the program with invalid operation: s == 1 (mismatched types Status and untyped int). You must compare it to another Status constant. The type system forces you to stay inside the boundaries you defined.
Convention aside: public constant names start with a capital letter. Private names start lowercase. Go does not use public or private keywords. Capitalization is the only visibility rule.
Building a production-ready enum
Real code needs more than integer constants. You need readable output for logs. You need validation when values arrive from JSON or user input. You need a way to iterate over the valid options.
Here is how a complete enum pattern looks in practice:
package main
import (
"fmt"
"strings"
)
// Status represents a user account state.
type Status int
const (
// Unknown is the zero value.
StatusUnknown Status = iota
// Active means the account is usable.
StatusActive
// Suspended means the account is blocked.
StatusSuspended
)
// String returns a human-readable name for logging.
func (s Status) String() string {
// Map the integer to a display string.
switch s {
case StatusActive:
return "active"
case StatusSuspended:
return "suspended"
default:
return "unknown"
}
}
The String() method follows the standard library convention. Any type that implements String() string will automatically use that method when passed to fmt.Println or log.Printf. The receiver name s is one letter matching the type. Go convention favors short receiver names over self or this.
You still need to handle invalid input. JSON unmarshaling will happily write any integer into your Status field. You must validate it after deserialization.
func (s *Status) UnmarshalJSON(data []byte) error {
// Parse the raw integer from the JSON payload.
var raw int
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Check if the value falls within the valid range.
if raw < 0 || raw > int(StatusSuspended) {
return fmt.Errorf("invalid status: %d", raw)
}
// Assign the validated value.
*s = Status(raw)
return nil
}
This custom unmarshaler catches bad data before it leaks into your business logic. The range check relies on the fact that iota produces a contiguous sequence. If you skip values in your const block, you must adjust the upper bound accordingly.
Trust the type system. Wrap the value or change the design.
Where things break
The pattern is simple, but the edges are sharp. The most common mistake is forgetting the custom type. If you write const ( A = iota B ) without a type, the compiler treats them as untyped constants. They will implicitly convert to int, float64, or whatever the context demands. You lose the boundary. You end up comparing statuses to random counters.
Another trap is mixing iota with explicit values in the same block. You can skip numbers by repeating iota or inserting a literal.
const (
// Zero starts at 0.
Zero = iota
// One becomes 1.
One
// Skip two numbers by adding a literal.
Three = 3
// Four becomes 4.
Four = iota
)
This works, but it breaks the range validation pattern. Your UnmarshalJSON check will reject 3 if it expects a contiguous sequence. Keep iota blocks contiguous unless you have a strong reason to fragment them.
Runtime panics happen when you pass an invalid integer to a function that expects a Status. Go will not panic on the assignment itself. It will panic later when you switch on the value and forget the default case, or when you index a slice using the enum as an index. Always include a default case in switches over enums. The worst goroutine bug is the one that never logs. The same applies to invalid enum states.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Validation errors for enums should follow the same pattern. Return early. Do not swallow the error.
Choosing your representation
Go gives you multiple ways to model fixed sets of values. Each approach trades off type safety, serialization behavior, and runtime flexibility. Pick the tool that matches your data flow.
Use iota with a custom type when you need compile-time type safety and zero runtime overhead. Use plain string constants when your values come from external APIs and you want direct JSON compatibility without custom unmarshalers. Use a struct-based enum when you need to attach methods, metadata, or validation logic to each individual value. Use a simple slice of strings when the set is small, stable, and only used for display purposes.
The language does not force a single pattern. It forces you to make the tradeoff explicit.