With no sum types in Go, how do you model a closed set of variants safely?
answer
- a closed set is a convention here
- names, not a checked value set
- any integer converts to Status
- an unexported method seals an interface
- every switch needs a loud default
basics
~20 sGo has no sum types, so a closed variant set is a convention rather than a compiler guarantee. Integer constants with iota model a flat enum; an interface with an unexported method seals a family of types. Neither checks exhaustiveness.
solid answer
~50 sThere are two idioms and both leak. A named integer type with `iota` constants gives readable names, but any integer converts to that type, so a JSON body or a database row can hand you `Status(99)` and nothing objects. An interface with an unexported method seals implementations to the declaring package - the closest Go gets to a discriminated union, and the form to use when variants carry different data - but a type switch that omits a case still compiles. So the discipline lives where you control it: **validate at the boundary**, with a parse function or a `Valid()` method called wherever a value arrives from JSON, a database or a flag; and **make every switch fail loudly** with a `default` that errors rather than silently doing nothing. Reserve the zero value as the invalid member so an uninitialised struct is caught.
code
go · 12 linestype Status int
const (
StatusUnknown Status = iota // zero value is deliberately not a real state
StatusPending
StatusActive
StatusClosed
)
func (s Status) Valid() bool {
return s > StatusUnknown && s <= StatusClosed
}go deeper
Know the two shapes: a named integer type with iota constants for a flat enum, and an interface for a family of types. Know that Go will not stop you creating a value outside the constant set.
Explain why the type is only nominal — any integer converts in, the zero value is always a member, and a type switch is never checked for exhaustiveness. Be able to write the sealed-interface trick with an unexported method.
Show the production discipline: validate at every boundary where a value arrives from JSON, a database or a flag, reserve the zero value as invalid, give every switch a loud default, and cover the set with a table-driven test so a new variant breaks a test rather than a customer.
Be able to defend the omission rather than just work around it: closed variants would drag pattern matching and exhaustiveness rules into a deliberately small type system. Decide where your codebase pays for the convention and make that policy explicit in review.
## What is missing A sum type — a discriminated union — is a type whose value is exactly one of a fixed set of alternatives, where the compiler knows the set and can require you to handle all of it. Go has no such construct. It has a struct (a product: all fields at once) and an interface (an open set: anything that implements it), and nothing in between. Go 1.18 added type parameters, and constraints may contain union terms such as `interface{ int | string }`. This is **not** a sum type: a union constraint may only constrain a type parameter, never be used as the type of a variable, field or return value. As of Go 1.27 the omission still stands. ## Idiom 1: a named integer type with iota ```go type Status int const ( StatusUnknown Status = iota StatusPending StatusActive StatusClosed ) ``` This gives named values, a distinct type in signatures, and a natural place to hang `String()` and `Valid()` methods. What it does not give: - **Any integer converts in.** `Status(99)` is legal, and so is decoding JSON `{"status": 99}` into a `Status` field. The type restricts nothing about the value set. - **The zero value is a member.** Whichever constant is first is what an uninitialised struct field holds. That is why the block above spends `iota`'s zero on `StatusUnknown` — so a forgotten assignment is detectably wrong instead of silently "pending". The alternative is `iota + 1` to leave zero out of the set entirely. - **No exhaustiveness.** A `switch` over `Status` that handles three of four cases compiles, vets clean, and quietly does nothing for the fourth. ## Idiom 2: a sealed interface When the variants carry different data — which is where a real sum type earns its keep — the interface form is better: ```go type Node interface { isNode() } ``` An unexported method means only the declaring package can implement `Node`, because no other package can name `isNode`. Callers elsewhere can still hold, pass and type-switch on a `Node`; they just cannot add a variant. This is the standard trick for expression trees, protocol messages and state machines whose states differ in shape, and the standard library uses the shape in `go/ast`-style designs. The residual hole is the same one: a type switch over `Node` that misses `*Binary` compiles fine, and the compiler will not tell you when a fifth variant is added next quarter. ## The failure this actually causes A concrete case: an embedded rules engine evaluates user-authored expressions, and each node carries an operator modelled as `type Op int` with `iota` constants. Rules arrive as JSON. Someone posts an operator index one past the end — a typo, or a rule written against a newer build. `encoding/json` decodes it happily, because the target is an integer type and 12 is a valid integer. The evaluator's `switch op` has cases for the eight real operators and no `default`, so evaluation falls off the end and returns the zero value. The rule silently evaluates to false for every input, and nothing logs anything. The bug is found weeks later by someone asking why a rule never fires. Every layer behaved exactly as documented. The set was never closed; only the constant block suggested it was. ## The discipline that replaces the compiler 1. **Parse, don't convert, at every boundary.** Give the type a `Valid() bool` method or, better, a `ParseStatus(string) (Status, error)`, and call it wherever a value crosses in: JSON, a database column, a command-line flag, an RPC field. For JSON specifically, implementing `UnmarshalJSON` on the named type moves the check into the decode itself, so no caller can forget it. 2. **Never write a silent default.** Every switch over the set gets a `default` that returns an error, or panics in code where an out-of-set value is a programming bug. A silent fallthrough turns a data error into a wrong answer. 3. **Reserve the zero value.** Either make zero the explicit invalid member, or start the constants at `iota + 1`. Go zero-initialises everything, so the zero value will occur. 4. **Centralise the set.** One `switch` — a `String()`, a validator, or a small table — that lists all variants in one place, so adding a variant has one obvious place to fail and a reviewer has one place to look. 5. **Test the closure.** A table-driven test that iterates every declared constant and asserts the switch handles it is the cheapest available substitute for exhaustiveness checking, and it fails the day someone adds the ninth operator without touching the evaluator. ## Defending the omission The honest defence is not that sum types are unnecessary — they would help here — but that Go priced them against the whole language: adding a closed variant type means adding pattern matching, exhaustiveness rules, and interaction with interfaces, zero values and the existing type switch. Go chose to keep the type system small and pay in convention. Say that plainly, and then show the conventions, because that is what a senior engineer is actually expected to enforce in review.
- What stops code outside the package from adding a variant to a sealed interface?An unexported method in the interface. Another package cannot declare a method whose name it cannot reference, so it cannot satisfy the interface. It can still hold, pass and type-switch on values of that interface type — only implementing is closed, which is exactly the property a discriminated union needs.
- Didn't Go 1.18 give us sum types through union constraints?No. A union term such as `int | string` is only legal inside a constraint on a type parameter; it cannot be the type of a variable, field, parameter or return value. It restricts which types may instantiate a generic function, not which values a variable may hold. As of Go 1.27 there is still no sum type.
- Where would you put the validation for a Status decoded from JSON?In an `UnmarshalJSON` method on the named type, so the check happens during decode and no caller can skip it. A `Valid()` method plus a call at every entry point works too, but relies on discipline at every site. Either way the check must exist, because encoding/json will happily store 99 in a Status field.
- How do you notice when someone adds a variant and forgets a switch?A table-driven test that walks every declared constant and asserts the behaviour under test handles it — no silent zero value, no default error. It is the cheapest substitute for exhaustiveness checking and it fails on the commit that adds the variant rather than in production weeks later.
saying these in an interview costs you the question
- Claims the compiler rejects a Status value outside the const block
- Thinks a type switch is checked for exhaustiveness
- Says union constraints in generics are sum types
- Leaves the zero value as a meaningful state by accident
- Writes a switch with no default over a decoded enum