iota is the constant counter Go builds enums out of. Go has no enum keyword, so a defined type, a block of iota constants, and a few methods do the job, with one gap the compiler will not close for you.
1 Declare a type and its constants
There is no enum type to declare and no compiler feature to reach for. You declare a type whose underlying type is int, then declare constants of that type.
The type is what makes it useful. A function taking a Status will not accept a plain int variable, so unrelated numbers cannot wander in.
type Status int
const (
StatusPending Status = iota
StatusActive
StatusClosed
)
func notify(s Status) { /* ... */ }
func main() {
notify(StatusActive) // fine
var n int = 1
// notify(n) // compile error: cannot use n (int) as Status
}2 Number the constants with iota
Inside a const block, iota is the index of the current line, starting at zero. Lines after the first repeat the previous expression, which is why the later names need nothing written after them.
It resets to zero in every new const block. Writing iota outside one is a compile error.
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
)
const (
Red int = iota // 0 again, new block
Green // 1
)3 Reserve zero for "unset"
A struct field of an enum type starts at zero whether or not anyone assigned to it. If zero is also a real member, an unset field is indistinguishable from a deliberate one.
Assigning iota to the blank identifier burns the zero slot, leaving it free to mean "unknown".
type Priority int
const (
PriorityUnknown Priority = iota // 0, the zero value
PriorityLow // 1
PriorityHigh // 2
)
type Ticket struct {
Title string
Prio Priority
}
func main() {
var t Ticket
fmt.Println(t.Prio == PriorityUnknown) // true
}4 Add a String method
Without one, fmt.Println prints the number. Any type with a String() string method satisfies fmt.Stringer, and every verb in fmt that prints a value will call it.
An array indexed by the constant is the compact version. Guard the index, since the method can be called on a value outside the range.
var statusNames = [...]string{"pending", "active", "closed"}
func (s Status) String() string {
if s < 0 || int(s) >= len(statusNames) {
return fmt.Sprintf("Status(%d)", int(s))
}
return statusNames[s]
}
func main() {
fmt.Println(StatusActive) // active
fmt.Println(Status(9)) // Status(9)
}5 Validate at the boundary
This is where the pattern stops behaving like an enum in other languages. Status(42) compiles, and so does arithmetic on a Status. The type keeps unrelated types out, and it does nothing about a wrong number of the right type.
Give the type a Valid method and a parser, then call them wherever a value arrives from outside the program.
func (s Status) Valid() bool {
return s >= StatusPending && s <= StatusClosed
}
func ParseStatus(name string) (Status, error) {
for i, n := range statusNames {
if n == name {
return Status(i), nil
}
}
return 0, fmt.Errorf("unknown status %q", name)
}
func main() {
fmt.Println(Status(42).Valid()) // false
s, err := ParseStatus("active")
fmt.Println(s, err) // active <nil>
}6 Control the JSON with MarshalText
By default a int-based enum encodes as a bare number, so the wire format breaks the moment someone inserts a constant in the middle of the block.
encoding/json uses encoding.TextMarshaler when a type has it, which gets you names on the wire and validation on the way back in. The same two methods also cover YAML and most other encoders.
func (s Status) MarshalText() ([]byte, error) {
if !s.Valid() {
return nil, fmt.Errorf("invalid status %d", int(s))
}
return []byte(s.String()), nil
}
func (s *Status) UnmarshalText(b []byte) error {
parsed, err := ParseStatus(string(b))
if err != nil {
return err
}
*s = parsed
return nil
}UnmarshalText takes a pointer receiver, because it has to change the value it was called on.
7 Use a string type instead
A defined string type gives the same compile-time separation with no lookup table and no marshalling methods. What it costs is the automatic numbering and the ability to compare with < and >.
Reach for it on values that live in config files and HTTP payloads. Reach for the int form when the values are ordered, or when there are enough of them for the size to matter.
type Env string
const (
EnvDev Env = "development"
EnvProd Env = "production"
)
func (e Env) Valid() bool {
switch e {
case EnvDev, EnvProd:
return true
}
return false
}Putting it together
One program with the whole pattern: the type, the constants, a name table, String, validation, a parser, and JSON that round-trips as names.
package main
import (
"encoding/json"
"fmt"
)
type Status int
const (
StatusPending Status = iota
StatusActive
StatusClosed
)
var statusNames = [...]string{"pending", "active", "closed"}
func (s Status) String() string {
if !s.Valid() {
return fmt.Sprintf("Status(%d)", int(s))
}
return statusNames[s]
}
func (s Status) Valid() bool {
return s >= StatusPending && s <= StatusClosed
}
func ParseStatus(name string) (Status, error) {
for i, n := range statusNames {
if n == name {
return Status(i), nil
}
}
return 0, fmt.Errorf("unknown status %q", name)
}
func (s Status) MarshalText() ([]byte, error) {
if !s.Valid() {
return nil, fmt.Errorf("invalid status %d", int(s))
}
return []byte(s.String()), nil
}
func (s *Status) UnmarshalText(b []byte) error {
parsed, err := ParseStatus(string(b))
if err != nil {
return err
}
*s = parsed
return nil
}
type Ticket struct {
Title string `json:"title"`
Status Status `json:"status"`
}
func main() {
t := Ticket{Title: "Ship the enums page", Status: StatusActive}
out, _ := json.Marshal(t)
fmt.Println(string(out))
var back Ticket
if err := json.Unmarshal(out, &back); err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(back.Status, back.Status.Valid())
if err := json.Unmarshal([]byte(`{"status":"deleted"}`), &back); err != nil {
fmt.Println("decode failed:", err)
}
fmt.Println(Status(42), Status(42).Valid())
}$ go run main.go
{"title":"Ship the enums page","status":"active"}
active true
decode failed: unknown status "deleted"
Status(42) falseA int-backed type that prints as a name, refuses an unknown name on the way in from JSON, and reports an out-of-range value as invalid rather than pretending it is a member.