Skip to content
gohacks.dev
Basics

Enums in Go Done Properly With Constants and Methods

A complete Go enum has a named type, constants, String, Parse, Valid and text marshaling. See int and string versions and when to pick each.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

An enum is a closed set of named values, and Go gives you the pieces without the package. You build one yourself, every Go project builds the same few lines, and the difference between a sloppy enum and a careful one is five methods. This how-to builds the careful version for a Status type, shows the string-backed alternative, and covers the places where enums go wrong in production: values that pass the compiler and not the sense check, numbers that change meaning in storage and switches that miss a case. The mechanics of iota are in constants and iota, and the Stringer generator is in the next article. This article belongs in the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The complete int-backed enum

main.goGo
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io"
)

type Status int

const (
	StatusUnknown Status = iota
	StatusActive
	StatusSuspended
	StatusClosed
	statusEnd
)

var statusNames = [...]string{
	StatusUnknown:   "unknown",
	StatusActive:    "active",
	StatusSuspended: "suspended",
	StatusClosed:    "closed",
}

// Statuses lists every valid value, for validation and help text.
func Statuses() []Status {
	return []Status{StatusActive, StatusSuspended, StatusClosed}
}

func (s Status) Valid() bool { return s > StatusUnknown && s < statusEnd }

func (s Status) String() string {
	if s < 0 || s >= statusEnd {
		return fmt.Sprintf("Status(%d)", int(s))
	}
	return statusNames[s]
}

func ParseStatus(name string) (Status, error) {
	for _, s := range Statuses() {
		if s.String() == name {
			return s, nil
		}
	}
	return StatusUnknown, 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 Account struct {
	Name   string `json:"name"`
	Status Status `json:"status"`
}

func main() {
	fmt.Println(StatusActive, Status(9), StatusUnknown.Valid(), StatusClosed.Valid())

	out, err := json.Marshal(Account{"ada", StatusSuspended})
	fmt.Println(string(out), err)

	var a Account
	fmt.Println(json.Unmarshal([]byte(`{"name":"grace","status":"closed"}`), &a), a.Status)
	fmt.Println(json.Unmarshal([]byte(`{"name":"x","status":"paused"}`), &a))

	_, err = json.Marshal(Account{"bad", Status(42)})
	fmt.Println(err)

	var zero Account
	fmt.Println(zero.Status, zero.Status.Valid())

	fs := flag.NewFlagSet("app", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	chosen := StatusActive
	fs.TextVar(&chosen, "status", chosen, "account status")
	fmt.Println(fs.Parse([]string{"-status", "closed"}), chosen)
	fmt.Println(fs.Parse([]string{"-status", "bogus"}))
}
Text
active Status(9) false true
{"name":"ada","status":"suspended"} <nil>
<nil> closed
unknown status "paused"
json: error calling MarshalText for type main.Status: invalid status 42
unknown false
<nil> closed
invalid value "bogus" for flag -status: unknown status "bogus"

Follow the output line by line. String prints names, and an out-of-range value prints Status(9) instead of panicking. Valid rejects the unknown zero value and the sentinel at the end. MarshalText writes the name into JSON, and an invalid value fails to encode with a clear error, so a bug that produces Status(42) cannot silently reach a database. UnmarshalText accepts a known name and rejects paused, naming the bad value. A zero Account has StatusUnknown, which is not valid, so code that forgot to set the status is caught by Valid and not treated as the first real choice.

The pieces, and why each is there:

  • A named type. Status is distinct from int, so a function that takes a Status will not accept an arbitrary number without a conversion, as type conversion explains.
  • Zero means unknown. The first constant is a placeholder that no real value can be, following the zero value rule.
  • A sentinel at the end. statusEnd marks where the valid range stops, so Valid and String stay correct when you add a value above it.
  • One list of values. Statuses() is the single source for parsing, help text and tests, so a new constant needs one new line there.
  • Text marshaling. Implementing MarshalText and UnmarshalText makes the encoder write names in JSON, YAML (with most libraries), flag values and map keys. The numbers are then free to change.

A string-backed enum

The alternative stores the name itself:

main.goGo
package main

import (
	"fmt"
	"slices"
)

type Color string

const (
	Red   Color = "red"
	Green Color = "green"
	Blue  Color = "blue"
)

var colors = []Color{Red, Green, Blue}

func (c Color) Valid() bool { return slices.Contains(colors, c) }

func ParseColor(s string) (Color, error) {
	c := Color(s)
	if !c.Valid() {
		return "", fmt.Errorf("unknown color %q", s)
	}
	return c, nil
}

func main() {
	fmt.Println(Red, Color("purple").Valid())
	fmt.Println(ParseColor("green"))
	fmt.Println(ParseColor("mauve"))

	var unset Color
	fmt.Printf("%q %t\n", unset, unset.Valid())
}
Text
red false
green <nil>
 unknown color "mauve"
"" false

No String method is needed, because the value is its own name, and JSON, SQL and logs already show it. The zero value is the empty string, which is not in the list, so Valid rejects it and "unset" is again distinguishable from a real choice. Reordering or inserting constants cannot change a stored value, since there are no numbers, and a database column reads suspended and not 2. The costs are memory, a string header of 16 bytes against 8 for an int, and speed, since comparing strings is slower than comparing integers, which matters in a hot loop and not in business code. Color("purple") compiles, so, as with the int version, the Valid check at the boundary is the guard.

The same methods serve flags and databases

Because the enum implements encoding.TextMarshaler and TextUnmarshaler, it plugs into more than JSON. The flag package's TextVar, added in Go 1.19, binds a flag to any such type, and the last lines of the program above show it accepting closed and rejecting bogus with an error that carries the enum's own message. Command line help and configuration files get the same validation for free.

For SQL, database/sql wants the driver.Valuer and sql.Scanner interfaces. For a string-backed or name-backed enum, the implementation is a few lines each: Value returns s.String(), and Scan accepts a string or []byte and calls ParseStatus. Storing the name keeps the column readable and the rows meaningful when someone queries them by hand, and a database CHECK constraint or an enum column type can enforce the same list on the server side. The one thing to decide is what the database does with an unknown value that was written by a newer version of the program: failing loudly in Scan is safer than mapping it to StatusUnknown and hiding the mismatch.

Choosing between them

Concernint with iotastring constants
Stored in a database or sent over the wireNeeds MarshalText and stable numbersWorks as is
Used as an index into an array of dataNaturalNeeds a map
Compared in a hot pathFastestSlower
Readable in a debugger and in logsNeeds StringAlready readable
Reordering the sourceChanges the numbersSafe
Bit flagsYes, with shiftsNo

I default to string-backed enums for anything that leaves the process, and to int-backed with text marshaling when the value is used heavily in memory. There is a third option that I rarely use, a struct type with unexported fields and exported variables for the values, which makes it impossible to construct an invalid value from outside the package. It gives the strongest guarantee, since Status(42) does not compile, and it costs the ability to use the values in a switch over constants and as map keys without extra care. For most code the Valid check at the boundary is enough protection. Whichever you choose, keep the number stable by adding explicit values or by never storing the integer.

Test that every value is covered

A test over the list of all values catches a constant that was added without a name or a parse entry:

status.goGo
package status

import "fmt"

type Status int

const (
	Unknown Status = iota
	Active
	Suspended
	Closed
	end
)

var names = [...]string{Unknown: "unknown", Active: "active", Suspended: "suspended", Closed: "closed"}

func All() []Status { return []Status{Active, Suspended, Closed} }

func (s Status) String() string {
	if s < 0 || s >= end {
		return fmt.Sprintf("Status(%d)", int(s))
	}
	return names[s]
}

func Parse(name string) (Status, error) {
	for _, s := range All() {
		if s.String() == name {
			return s, nil
		}
	}
	return Unknown, fmt.Errorf("unknown status %q", name)
}
status_test.goGo
package status

import "testing"

func TestEveryValueRoundTrips(t *testing.T) {
	if len(names) != int(end) {
		t.Fatalf("names has %d entries for %d statuses", len(names), end)
	}
	for _, s := range All() {
		got, err := Parse(s.String())
		if err != nil || got != s {
			t.Errorf("Parse(%q) = %v, %v; want %v", s.String(), got, err, s)
		}
	}
	if _, err := Parse("nope"); err == nil {
		t.Error("Parse accepted an unknown name")
	}
}
Text
=== RUN   TestEveryValueRoundTrips
--- PASS: TestEveryValueRoundTrips (0.00s)
PASS
ok  	example.test/lab	1.273s

The first check compares the length of the names array with the number of values, which fails when you add a constant and forget its name. The loop proves that String and Parse agree for every valid value. That is the whole safety net for the enum, and it is short enough to copy into each new one. For a switch over an enum, the exhaustive linter flags a missing case, and without a linter the discipline is a default that panics or returns an error. The article on the Stringer generator removes the hand-written names, and the Go blog on constants and the specification's iota section cover the mechanics the enum is built on.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.