
Introduction
Go has no real enums and no Option type because it builds both ideas from smaller pieces instead of adding a dedicated construct for each. A fixed set of values is expressed with a named type and typed constants, usually generated with iota, and a missing value is expressed with a zero value, a pointer, a comma-ok lookup, an error return, or a Valid flag, depending on the context. This article covers both halves in turn: what the iota pattern gives and what it cannot guarantee, and how each missing-value pattern works and where each one breaks down.
Before You Start
- Enum: a type whose values are a fixed, named set, such as draft, published, and archived. In some languages the compiler enforces that set. Go has no enum keyword, so the set is a convention enforced by code the author writes.
- Closed set: a set of values that cannot gain a new member outside its definition. A Go typed constant set is not closed, because any code can construct another value of the same underlying type.
- Exhaustiveness checking: a compiler check that a switch or match handles every member of a set. Go does not perform this check for a typed constant set, so a new member can arrive without a compiler error at an existing switch.
- Iota: the predeclared identifier in a constant declaration whose value is the index of the respective constant specification in that declaration, starting at zero. It generates successive values inside one constant block.
- Typed constant: a constant declared with an explicit type, such as
Status, so the constant carries that type rather than adapting to each context. - Zero value: the value a variable holds when it is declared without an explicit initial value. For numbers it is
0, for strings it is"", for booleans it isfalse, and for pointers, slices, maps, channels, and interfaces it isnil. - Pointer: a value that holds the address of another value, or
nilwhen it holds no address. A*stringfield can therefore mean "no string supplied" when it isnil, and "a string was supplied" when it points to one. - Comma-ok: the two-value form of a map lookup, a type assertion, or a channel receive, written
value, ok := m[key]. The boolean reports whether the key was present, which separates a missing entry from an entry that holds the zero value. - Option type: a wrapper type that holds either a value or an explicit absence of a value, so absence is part of the type and must be handled before the value is used. Go has no such type in the standard library.
- Valid flag: a boolean field paired with a value, as in
database/sqlNullString, whereValidis true when the value is not null and false when it is null.
Prerequisites
- Go 1.20 or later, because the article uses only standard library features that are stable in that version.
- A terminal, and a text editor.
- No third party packages are required for the complete example.
go version
Requires: Go 1.20 or later.
Project Layout
enum-option/
├── go.mod # module declaration, Go version
├── main.go # Status type, validation, JSON methods, lookup, and main function
└── main_test.go # validation, JSON, lookup, and pointer tests
- go.mod: declares the module and the minimum Go version.
- main.go: holds the enum pattern and the missing-value patterns, so the two halves can be read side by side.
- main_test.go: holds tests that prove validation rejects out-of-range values, JSON rejects unknown names, a comma-ok lookup separates a missing key from an explicit unknown value, and a nil pointer separates no summary from a supplied summary.
What An Enum Means In Go
Go expresses a fixed set of values with three ingredients: a named type with an integer underlying type, a constant block, and iota to number the members.
type Status int
const (
StatusUnknown Status = iota
StatusDraft
StatusPublished
StatusArchived
)
The named type matters. Status is distinct from int at the type level, so a function that takes a Status does not accept a bare int without a conversion, and a Status value does not mix with an unrelated integer type by accident. The underlying type is still int, which is why the set is not closed: Status(99) is a value of type Status, even though no constant in the block names it.
iota numbers the block. Its value is the index of the constant specification, starting at zero, so StatusUnknown is 0, StatusDraft is 1, StatusPublished is 2, and StatusArchived is 3. A later line repeats the previous expression implicitly, which is why only the first line in the block writes iota out. A new constant block resets iota to zero, so values from two blocks are unrelated unless the author numbers them to match.
Starting the block with StatusUnknown is a deliberate design choice, and it has a cost that shows up later in this article. The zero value of Status is StatusUnknown, which is a member of the set, so a Status field that was never assigned holds a value that reads as a real status. An author who wants the zero value to mean "not set" can start the block with a blank identifier to skip zero, then name the first real member at 1, but that choice makes the zero value invalid instead of unknown, and every consumer must then treat zero as an error. There is no numbering that gives both a meaningful zero value and a zero value that means "not set". That tradeoff is the first sign that Go leaves enum semantics to the author.
A String method gives the set readable output. The stringer tool automates this: given a signed or unsigned integer type with constants defined, it creates a file implementing func (t T) String() string, translating each constant value to its name, and it works best with consecutive values such as those created with iota. A hand-written String method does the same work with a switch, and it must decide what an out-of-range value prints. Returning a word such as "invalid" keeps printing safe, and it does not make the value valid. Printing and validation are separate concerns, and only validation can reject a value.
What The Iota Pattern Gives And What It Lacks
The iota pattern gives three things that cover most enum use.
First, it gives named, typed values. Code reads StatusPublished instead of 2, and a function signature that takes Status documents its input in the type itself. Second, it gives cheap, ordered values that work as map keys, slice indices, and switch cases, because the underlying type is an integer. Third, it gives a compact declaration: one block, one iota, and the members number themselves, with expressions such as 1 << iota available when the set is a bit flag set rather than a sequence.
It lacks four things that a dedicated enum type would provide, and each gap has a concrete failure mode.
No closed set. Any code can write Status(99), Status(-1), or a value read from a file, a network response, or a database row, and the compiler accepts it. The type system checks the type, not membership in the set. A function that trusts its Status argument without validation can therefore receive a value no constant names.
No exhaustiveness checking. A switch over Status with cases for every named member compiles without complaint when a default case is missing, and it keeps compiling when a new member is added later. The new member falls through to no case, or to default if one exists, and the compiler does not warn that the switch is now incomplete. Exhaustiveness is a property the author must maintain by review and by tests, not a property the compiler maintains.
No invalid value at the boundary. A value from outside the program has no automatic check. JSON decoding into a Status field, by default, decodes a number into the integer underlying type, so 99 arrives as Status(99) without an error. A string form needs a custom MarshalJSON and UnmarshalJSON, or MarshalText and UnmarshalText, and those methods must reject unknown names explicitly, or unknown input is silently accepted or silently mapped to a fallback. The complete example in this article rejects unknown values on both directions, and the tests prove it.
No name round trip by default. Without a String method, printing a Status prints its number, not its name, and without custom JSON methods, encoding prints the number as well. A log line that reads 2 instead of published is harder to read, and a JSON API that emits 2 couples its clients to the numbering of the block. If the block is reordered, the numbers change, and every stored or transmitted number changes meaning. String constants avoid the numbering problem at the cost of larger payloads and string comparisons, which is a tradeoff the author must choose per API, not a default the language supplies.
The practical consequence is a rule: validate at the boundary, and validate once, early. A Valid method, a constructor that returns an error for an out-of-range value, or a custom unmarshal method that rejects unknown input all serve the same purpose. Code inside the boundary can then trust the value, because the boundary already refused the rest.
What An Option Type Would Be, And What Go Uses Instead
An option type would make absence part of the type: a value is either present, carrying a value, or absent, carrying nothing, and the compiler would require the caller to handle both cases before using the value. Go has no such type in the standard library, and it does not need one for most code, because it already has several absence mechanisms, each with a different cost.
Zero value. Every Go variable has a zero value, so a field that was never set still holds a value. That works when the zero value is a safe default, such as an empty string for a name that is optional, or StatusUnknown for a status that starts unknown. It fails when the zero value is also a legitimate value, because then "not set" and "set to zero" are the same value and cannot be told apart. A quantity of 0 and a missing quantity are different facts, and a zero value cannot express both.
Pointer. A pointer adds a second state: nil means absent, and a non-nil pointer means present. A *string field for an optional summary therefore distinguishes "no summary supplied" from "an empty summary supplied", because the first is nil and the second points to "". The cost is indirection and nil handling: every use must check for nil before dereferencing, and a missed check panics. The full pointer tradeoffs, including the nil dereference risk, are the subject of the next topic in this series, on why a pointer is needed to say a value is missing, and the site docs on pointer fundamentals cover the mechanics.
Comma-ok. A map lookup with two values separates presence from value. value, ok := m[key] returns ok as true when the key is present, even when the stored value is the zero value, and false when the key is absent, in which case the value is the zero value. The complete example uses this to separate an explicit "unknown" entry from a missing key, even though both yield StatusUnknown as the value. Comma-ok is a lookup mechanism, not a field type, so it answers "was this key present" at the moment of lookup, and it does not travel with the value after the lookup.
Error return. A function that returns (T, error) separates success from failure, and a missing value that is an error condition, such as a record that must exist, belongs in the error return. That pattern does not suit an optional field, because "no value" is not an error for an optional field, and returning an error for every absent optional value would turn ordinary absence into failure handling at every call site.
Valid flag. The database/sql package defines types such as NullString, a struct with a String field and a Valid boolean, where Valid is true when the string is not null. That pattern suits database nulls, because a database column can be null in a way that is distinct from an empty string, and the Valid flag carries that distinction through scanning and into application code. The cost is verbosity: every use checks Valid before String, and the type is specific to its value type, so NullString, NullInt64, and NullFloat64 are separate types rather than one generic wrapper.
None of these patterns is an option type, and none of them enforces handling at compile time. A caller can ignore a Valid flag, dereference a nil pointer, or drop the ok boolean, and the compiler accepts each omission. The discipline is the same as with enums: the author writes the check, and tests prove the check exists.
Design Decisions And Tradeoffs
| Option | Use when | Cost |
|---|---|---|
| Typed constants with iota | A fixed set of values is needed, with cheap comparison and ordering | Set is not closed, and invalid values can be constructed or decoded |
| String constants | Values travel as text in JSON, logs, or configuration, and numbering must not change meaning | Larger payloads, string comparison, and no ordering guarantee |
| String method or stringer | Human readable output is needed for logs and errors | Generated file or hand-written switch must be kept in step with the constant block |
| Custom JSON methods | Unknown input must be rejected, and the wire form must be a name rather than a number | Both marshal and unmarshal directions must be written and tested, and a missed direction silently accepts bad input |
| Zero value as absence | Absence and the zero value mean the same thing in the domain | Cannot separate "not set" from "set to zero" |
| Pointer as absence | Absence must be distinct from every value, including the zero value | Nil checks at every use, and a missed check panics |
| Comma-ok lookup | Presence must be known at lookup time, as with a map entry | Presence does not travel with the value after the lookup |
| Error return | Absence is a failure that must stop the caller | Turns ordinary optional absence into failure handling at every call site |
| Valid flag, as in sql.NullString | A database null must be distinct from an empty value, and the distinction must travel with the value | Verbose checks at every use, and one type per value type |
| Generic wrapper type | One absence pattern is needed across many value types | Not in the standard library, so the author defines it, documents it, and maintains its JSON and database behaviour |
A design in this article has a deliberate compromise, stated openly. The complete example puts the enum type, the JSON methods, the lookup, and the pointer field in one main package, so both halves can be read in one place. In a real project the Status type would live in its own package, and the article field would live in a domain package, because a main package cannot be imported by other code. The single package here is a teaching compromise, not a production layout. The validation and presence rules themselves are not compromised: invalid values are rejected at the boundary, and presence is checked before use.
Complete Example
The complete example is an article status type and an article record. The status type is a typed constant set with validation and JSON methods. The article record uses a pointer for an optional summary, and a lookup function uses comma-ok to report presence.
// go.mod
module enumoption
go 1.20
// main.go
package main
import (
"encoding/json"
"fmt"
)
type Status int
const (
StatusUnknown Status = iota
StatusDraft
StatusPublished
StatusArchived
)
func (s Status) String() string {
switch s {
case StatusUnknown:
return "unknown"
case StatusDraft:
return "draft"
case StatusPublished:
return "published"
case StatusArchived:
return "archived"
default:
return "invalid"
}
}
func (s Status) Valid() bool {
switch s {
case StatusUnknown, StatusDraft, StatusPublished, StatusArchived:
return true
default:
return false
}
}
func (s Status) MarshalJSON() ([]byte, error) {
if !s.Valid() {
return nil, fmt.Errorf("invalid status %d", int(s))
}
return json.Marshal(s.String())
}
func (s *Status) UnmarshalJSON(data []byte) error {
var name string
if err := json.Unmarshal(data, &name); err != nil {
return err
}
switch name {
case "unknown":
*s = StatusUnknown
case "draft":
*s = StatusDraft
case "published":
*s = StatusPublished
case "archived":
*s = StatusArchived
default:
return fmt.Errorf("invalid status %q", name)
}
return nil
}
var statusByName = map[string]Status{
"unknown": StatusUnknown,
"draft": StatusDraft,
"published": StatusPublished,
"archived": StatusArchived,
}
func FindStatus(name string) (Status, bool) {
status, ok := statusByName[name]
return status, ok
}
type Article struct {
Title string
Status Status
Summary *string
}
func main() {
article := Article{Title: "Enums in Go", Status: StatusPublished}
fmt.Printf("status: %s\n", article.Status)
data, err := json.Marshal(article)
if err != nil {
fmt.Println("marshal error:", err)
return
}
fmt.Printf("json: %s\n", data)
status, ok := FindStatus("published")
fmt.Printf("lookup published: %s found=%v valid=%v\n", status, ok, status.Valid())
missing, found := FindStatus("retired")
fmt.Printf("lookup retired: %s found=%v valid=%v\n", missing, found, missing.Valid())
if article.Summary == nil {
fmt.Println("summary: none")
}
text := "A typed constant set."
withSummary := Article{Title: "Enums in Go", Status: StatusPublished, Summary: &text}
fmt.Printf("summary: %s\n", *withSummary.Summary)
}
Commands to run it, from the enum-option folder:
go run .
Expected output:
status: published
json: {"Title":"Enums in Go","Status":"published","Summary":null}
lookup published: published found=true valid=true
lookup retired: unknown found=false valid=true
summary: none
summary: A typed constant set.
Why this output reads the way it does, in order:
StatusPublishedprints aspublishedthrough theStringmethod, and marshals as"published"throughMarshalJSON, not as the number2.- The missing lookup returns
StatusUnknownwithfoundas false. That pair is the point of comma-ok: the value alone cannot tell a missing key from an explicit unknown entry, and the boolean can. - The article without a summary has a nil
Summarypointer, so the program printssummary: noneinstead of dereferencing it. The article with a summary holds a pointer to a string, so dereferencing is safe after the nil check pattern is established.
Testing
The test file proves the four claims the article makes about the complete example: validation, JSON rejection, comma-ok presence, and pointer presence.
// main_test.go
package main
import (
"encoding/json"
"testing"
)
func TestStatusValid(t *testing.T) {
valid := []Status{StatusUnknown, StatusDraft, StatusPublished, StatusArchived}
for _, s := range valid {
if !s.Valid() {
t.Errorf("status %d should be valid", int(s))
}
}
invalid := []Status{Status(-1), Status(99)}
for _, s := range invalid {
if s.Valid() {
t.Errorf("status %d should be invalid", int(s))
}
}
}
func TestStatusJSONRoundTrip(t *testing.T) {
data, err := json.Marshal(StatusPublished)
if err != nil {
t.Fatalf("marshal published: %v", err)
}
if string(data) != `"published"` {
t.Fatalf("marshal published = %s, want %q", data, "published")
}
var back Status
if err := json.Unmarshal(data, &back); err != nil {
t.Fatalf("unmarshal published: %v", err)
}
if back != StatusPublished {
t.Errorf("round trip = %v, want %v", back, StatusPublished)
}
var unknown Status
if err := json.Unmarshal([]byte(`"retired"`), &unknown); err == nil {
t.Error("unmarshal of retired should fail")
}
if _, err := json.Marshal(Status(99)); err == nil {
t.Error("marshal of status 99 should fail")
}
}
func TestFindStatusCommaOk(t *testing.T) {
status, ok := FindStatus("published")
if !ok || status != StatusPublished {
t.Errorf("lookup published = %v, %v, want published, true", status, ok)
}
explicit, explicitOk := FindStatus("unknown")
if !explicitOk || explicit != StatusUnknown {
t.Errorf("lookup unknown = %v, %v, want unknown, true", explicit, explicitOk)
}
missing, missingOk := FindStatus("retired")
if missingOk {
t.Errorf("lookup retired ok = true, want false")
}
if missing != StatusUnknown {
t.Errorf("lookup retired value = %v, want unknown", missing)
}
}
func TestOptionalSummaryPointer(t *testing.T) {
without := Article{Title: "Enums in Go", Status: StatusPublished}
if without.Summary != nil {
t.Error("article without summary should have a nil summary pointer")
}
text := "A typed constant set."
with := Article{Title: "Enums in Go", Status: StatusPublished, Summary: &text}
if with.Summary == nil {
t.Fatal("article with summary should have a non-nil summary pointer")
}
if *with.Summary != text {
t.Errorf("summary = %q, want %q", *with.Summary, text)
}
}
Commands to run the tests and the static check:
go vet ./...
go test ./...
Expected output for the tests is a pass for every test, including the JSON rejection tests, because both JSON directions reject unknown values, and the comma-ok test proves that a missing key and an explicit unknown entry yield the same value with different booleans. go vet reports suspicious constructs. It does not prove that a switch is exhaustive, and it does not prove that a JSON method rejects unknown input, so a clean vet run is not a substitute for the tests above.
Common Mistakes
- Trusting a decoded enum value: a
Statusdecoded from JSON, a database row, or a network response can hold a value no constant names, and code that switches on it without adefaultcase or aValidcheck silently drops it. The fix is validation at the boundary, before the value reaches business logic. - Treating the zero value as "not set" and as a real member at the same time: a block that starts with
StatusUnknownat zero makes the zero value a member, so an unassigned field reads as unknown, not as unset. The fix is to choose one meaning for zero, document it, and use a pointer or aValidflag when the other meaning is needed. - Adding a member without updating every switch: a new constant compiles cleanly against every existing switch, and the new member falls through to no case or to
default. The fix is a test that lists every member and asserts each one is handled, plus adefaultcase that returns an error rather than silently ignoring the value. - Reordering an iota block that is already stored or transmitted: numbers assigned by
iotafollow block order, so inserting a member in the middle renumbers every member after it, and stored numbers change meaning. The fix is to append new members at the end, or to use string constants for any value that leaves the process. - Using a String method as validation: a
Stringmethod that returns"invalid"for an out-of-range value keeps printing safe, and it does not reject the value. The fix is a separateValidmethod or constructor, and a check at the boundary. - Writing only one JSON direction: a custom
MarshalJSONthat rejects invalid values does not stopUnmarshalJSONfrom accepting unknown names, and the reverse is also true. The fix is to write and test both directions, with unknown input rejected in each. - Reading a map value without the boolean: a single-value lookup returns the zero value for a missing key, which is indistinguishable from an explicit zero entry. The fix is the two-value form,
value, ok := m[key], and a decision based onokbefore the value is used. - Dereferencing a pointer before a nil check: a
*stringfield that is nil panics on dereference, and the panic happens at the use site, far from the code that left the field unset. The fix is a nil check before every dereference, or a helper that returns a default for a nil pointer. - Using an error return for an optional field: a function that returns an error when an optional value is absent forces every caller to handle failure for ordinary absence. The fix is a pointer, a
Validflag, or a comma-ok result for optional absence, and an error return only when absence is a failure. - Checking a Valid flag once and caching the value: a
NullStringwhoseValidflag is checked in one place and whoseStringfield is read in another place can drift apart if the struct is copied or modified between the two places. The fix is to checkValidand read the value together, at the same site.
Production Notes
- Boundaries: validate enum values at every boundary where a value enters the program: JSON decoding, database reads, configuration parsing, and API responses. Inside the boundary, code can trust the value, because the boundary already refused the rest.
- Storage: store enum values as names, not numbers, when the stored form must survive a reordering of the constant block, a new member, or a migration between services. Numbers are compact and fast inside one process, and fragile as a stored form.
- API compatibility: adding a member is a backward compatible change for readers that ignore unknown values, and a breaking change for readers that reject them. Choose the rejection policy per API, document it, and test the unknown member case, because a client that rejects unknown values will fail on a new member even though the server change was additive.
- Observability: log enum values by name, not by number, so a log line can be read without the constant block in front of the reader. An out-of-range value in a log should read as invalid, and should trigger the same alert path as any other rejected input.
- Deployment: the example needs Go 1.20 or later. Pin that version in
go.mod, rungo vet ./...andgo test ./...in continuous integration, and do not deploy a change that fails the validation, JSON, or presence tests. A vet pass alone is not enough for an enum type, because vet does not prove that a switch handles every member.
Decision Guide
- When a set of values is fixed, small, and internal to one process: use typed constants with
iota, aStringmethod, and aValidmethod, with validation at the boundary. - When values travel as text, or must survive reordering: use string constants, or a typed constant set with custom JSON methods that encode names, and reject unknown names on decode.
- When absence and the zero value mean the same thing: use the zero value, and document that the zero value is the default.
- When absence must be distinct from every value, including zero: use a pointer, with a nil check before every dereference.
- When presence must be known at lookup time: use comma-ok, and decide on the boolean before using the value.
- When absence is a failure: use an error return, and let the caller handle the failure as an error.
- When a database null must travel with the value: use a
Validflag type such assql.NullString, and check the flag at the same site where the value is read. - When one absence pattern is needed across many types: define a generic wrapper type in your own code, with JSON and database behaviour written and tested for that wrapper, rather than assuming a standard library option type exists.
Conclusion
Go has no real enums and no Option type because it gives the author the pieces and leaves the guarantees to the author. Typed constants with iota give named, typed, ordered values, and they do not close the set, check exhaustiveness, or reject invalid input. Zero values, pointers, comma-ok, error returns, and Valid flags give absence in several forms, and none of them forces handling at compile time.
Summary Of Key Points
- Iota numbers a block:
iotais the index of the constant specification, starting at zero, and a named type keeps the set distinct from a bare integer. - The set is not closed: any code can construct
Status(99), so validation at the boundary is the only membership check. - No exhaustiveness check: a switch keeps compiling when a member is added, so tests and a rejecting
defaultcase must cover the new member. - JSON needs both directions: a custom marshal method that rejects invalid values does not stop an unmarshal method from accepting unknown names, and the reverse is also true.
- Zero value is not absence: a zero value cannot separate "not set" from "set to zero", so a pointer, a comma-ok boolean, or a
Validflag is needed when that distinction matters. - Presence checks are manual: a caller can ignore a
Validflag, drop anokboolean, or dereference a nil pointer, and the compiler accepts each omission, so tests must prove the checks exist.
Choose the pattern that matches the meaning of absence in your domain, validate enum values where they enter the program, and the absence of a dedicated enum or option type becomes a small, explicit cost rather than a recurring source of invalid values.
Related Posts
- Why Does My Nil Interface Not Equal Nil?
- Why Does Go JSON Turn My Empty Slice Into Null?
- Go Type Assertion Vs Type Switch
Learn more:
Additional Resources
- Go Documentation: Effective Go - constants, types, and the design of Go programs
- Go Documentation: The Go Programming Language Specification - constant declarations, iota, and the zero value
- Go Packages: stringer command - generating a String method for an integer type with constants
- Go Packages: encoding/json package - Marshal, Unmarshal, and custom JSON methods
- Go Packages: database/sql package - NullString, NullInt64, and the Valid flag pattern
References
- Effective Go. Go team, Go Documentation. https://go.dev/doc/effective_go. Accessed 2026-10-25.
- The Go Programming Language Specification. Go team, Go Documentation. https://go.dev/ref/spec. Accessed 2026-10-25.
- stringer command. Go team, Go Packages. https://pkg.go.dev/golang.org/x/tools/cmd/stringer. Accessed 2026-10-25.
- encoding/json package. Go team, Go Packages. https://pkg.go.dev/encoding/json. Accessed 2026-10-25.
- database/sql package. Go team, Go Packages. https://pkg.go.dev/database/sql. Accessed 2026-10-25.