Go

How to Implement Enums in Go with iota and Typed Constants

Learn how to implement a Golang enum with iota, typed constants, String methods, sentinel iteration, skipped values, and validation.

By Niko Minadze5 min read

Editorial illustration for How to Implement Enums in Go with iota and Typed Constants

Go has no enum keyword. The usual practical replacement is a named integer type combined with a const block and iota:

type Color int

const (
    Red Color = iota
    Blue
    Green
)

That gives you named values in a distinct type without much machinery. It does not create a closed set: code can still construct something like Color(99). If values come from an API, database, configuration file, or command-line argument, validate them explicitly.

What “enum” means in Go

Go does not directly support enums, so “enum” generally describes a convention built from existing language features. A basic version uses untyped constants:

const (
    Red = iota
    Blue
    Green
)

fmt.Println(Red, Blue, Green) // 0 1 2

Within this const block, iota starts at zero and advances for each constant specification. When an expression is omitted, the previous expression is repeated, with the current value of iota. A new const declaration resets iota to zero.

This bare form is concise, but it does not define a named Color domain. The constants remain ordinary integer constants and can be used where other compatible integer values are accepted. Defining a custom type provides better type safety, as described in this overview of custom enum types.

Add a named type

Define the type first, then annotate the first member:

type Color int

const (
    Red Color = iota // 0
    Blue             // 1
    Green            // 2
)

The omitted declarations repeat Color = iota, so all three constants have type Color. This helps keep a color separate from unrelated named integer types.

The underlying type does not have to be int; an integer type appropriate to the program can be used. More importantly, choose the numeric assignments deliberately if they will cross a storage or network boundary.

Start at one, scale values, or leave gaps

Zero is often useful as an undefined or default state. Add an offset when the first real member should start at one:

type Color int

const (
    Red Color = iota + 1 // 1
    Blue                 // 2
    Green                // 3
)

iota can participate in other constant expressions. For example, this sequence produces even values:

const (
    Red Color = (iota + 1) * 2 // 2
    Blue                       // 4
    Green                      // 6
)

Use the blank identifier when a numeric position must remain unused:

const (
    Red Color = iota + 1 // 1
    _                    // 2 is unused
    Blue                 // 3
    Green                // 4
)

The blank declaration still occupies a position in the sequence. It simply avoids declaring a usable name for that value. Both offsets and skipped values are illustrated in this discussion of iota constant sequences.

Iterate with sentinel constants

Go does not provide built-in enum iteration. For a contiguous sequence, boundary sentinels can define the range:

package main

import "fmt"

type Color int

const (
    StartColor Color = iota
    Red
    Blue
    Green
    EndColor
)

func main() {
    for color := StartColor + 1; color < EndColor; color++ {
        fmt.Println(color)
    }
}

The loop starts after StartColor and stops before EndColor, so it visits exactly Red, Blue, and Green. This pattern depends on the members being contiguous and ordered. If the enum intentionally contains gaps, an explicit slice of members is clearer than numeric iteration.

Print human-readable names

Without additional behavior, the values above print as integers. Add a String() method to map members to names:

func (c Color) String() string {
    switch c {
    case Red:
        return "Red"
    case Blue:
        return "Blue"
    case Green:
        return "Green"
    default:
        return "UnknownColor"
    }
}

A Color now satisfies fmt.Stringer, and the fmt package uses that interface when formatting the value. With the sentinel loop and method combined, the output is:

Red
Blue
Green

The default branch matters because the named type is not a closed set. It gives formatting predictable behavior for values such as Color(99). The relationship between String() and fmt.Stringer is explained in this enum formatting example.

Validate values at the boundary

A String() default does not make an unknown value valid. Use a separate validation function when accepting external input:

package color

import "fmt"

func ValidateColor(c Color) error {
    switch c {
    case Red, Blue, Green:
        return nil
    default:
        return fmt.Errorf("invalid color: %d", c)
    }
}

Call validation after input has been bound or converted to Color, but before the value enters business logic or is persisted:

color := Color(99)
if err := ValidateColor(color); err != nil {
    // Reject the input.
}

This returns an error rather than accepting the out-of-range value. Keeping validation separate from formatting also avoids treating the string "UnknownColor" as evidence that the value is acceptable.

Treat numeric assignments as persistent data

The convenience of iota comes with an important tradeoff: inserting, removing, or reordering members changes the values of later constants. That can silently reinterpret existing database rows or other stored data. A value that previously meant Blue could mean Red after an unsafe edit.

Once numeric values become part of persisted data or an external protocol, preserve their assignments. Consider writing explicit values instead of relying on position:

const (
    Red   Color = 1
    Blue  Color = 2
    Green Color = 3
)

The declarations are slightly more repetitive, but their meaning no longer depends on source order. The risk of changing established iota declarations is also noted in this discussion of persisted enum values.

TaggedGo

Comments

No comments yet.

Leave a comment

Your comment appears after approval. Plain text only; links stay as text. Line breaks and indentation are kept.

Up to 5,000 characters. No account or email needed.

All articles

Type at least two characters.

Press <kbd>Esc</kbd> to closeOpen the search page