Sum types in Go: who keeps the tag honest?

Read a discriminant, branch, reach the payload — same operation either way. The only real difference is who keeps the tag and the payload in agreement.

rust enum + match — compiler rejects
a missed arm
go — no equivalent, either shape below
DEFAULT
Go — tagged struct
presence is the tag — no separate discriminant field
type Circle struct{ R float64 }
type Rect   struct{ W, H float64 }
type Shape struct {
  Circle *Circle `json:"circle,omitempty"`
  Rect   *Rect   `json:"rect,omitempty"`
}
func (s Shape) Area() float64 {
  a := 0.0
  if s.Circle != nil { a += math.Pi*s.Circle.R*s.Circle.R }
  if s.Rect != nil   { a += s.Rect.W*s.Rect.H }
  return a
}
Go already ships a sum type. Nothing enforces it.
return 42, errors.New("boom")  // both set, compiles
return 0,  nil                  // both zero, compiles
(T, error) is Go’s most-used two-variant sum type. No type stops either case — the whole ecosystem runs on “check err first,” and it works.
None set → 0. Both set → summed. Neither is a bug — same shape as search filters, PATCH bodies, config, where “none set” is legal too. Nothing to be exhaustive over.
Presence is the tag — no separate discriminant to disagree with the payload. That’s structural here, not something a constructor has to maintain. Honest cost: a pointer indirection and a heap allocation per variant that’s actually set.
NARROW CASE
Go — sealed interface
when exactly-one is safety-critical
type Shape interface{ shape() }
switch s := s.(type) {
case Circle: ...
case Rect:   ...
}
✓ Tag = itab pointer, set by the compiler when boxed — can’t disagree with the payload. The invariant is free and unbreakable.
! Costs a 2-word interface value and a heap escape for the payload. Still no exhaustiveness check — a missed case just compiles.
Earns its keep when “exactly one” is load-bearing:
protocol decoders — a byte on the wire selects the type
state machines — the state determines the payload shape
ledger / billing records — mixing fields is a real bug, not a style nit
Want both guarantees anyway? Unexport the variant fields, add constructors (OfCircle, OfRect) — the same trick as this interface: an unexported identifier behind a package boundary. Pair with a Match(onCircle, onRect) func, one handler per variant, and a missing handler stops every call site compiling. Usually not worth the ceremony — the bill either way: no early return from a handler, closures may allocate, JSON needs a hand-written boundary type.

A struct with optional fields is not TypeScript’s idiom — TS narrows a kind-tagged discriminated union like the sealed interface, checked exhaustively via never. Go has neither, built in.

verified: go1.27rc2, rustc 1.97.1, tsc 7.0.2 · go-from-rust