skip to content

Go's compiler never checks that a switch covers every constant of a type — how do you stop an unhandled case from failing silently?

level: seniorimportance: should knowfreq 40%

answer

  1. the compiler will not help you here
  2. make the miss loud, not silent
  3. let a test count the constants
  4. coverage shows a clause never entered

basics

~20 s

Go has no exhaustiveness check, so make the gap loud instead of silent: end the switch with a default that panics or returns an error rather than doing nothing, and add a test that walks every declared constant through it.

solid answer

~50 s

Nothing in the toolchain will tell you. Neither the compiler nor `go vet` checks that a switch over a set of `iota` constants handles them all, and a forgotten one simply takes `default` or falls out of the switch leaving a zero value. So the defence is design plus tests, in three layers. First, never let the miss be quiet: a switch that maps a value should end in a `default` that panics for a programmer error, or returns an explicit error for values that can arrive from outside — never an empty clause. Second, declare a sentinel constant after the last real one, `numTokenKinds`, and write a table-driven test that loops from the first constant to the sentinel and asserts each value produces a real answer; adding a kind then fails the test until the switch is extended. Third, `go test -coverprofile` plus `go tool cover -html` shows any case body the tests never entered.

code

go · 16 lines
go
type tokenKind int

const (
	tokIdent tokenKind = iota
	tokNumber
	tokString
	numTokenKinds // sentinel: always last
)

func TestEveryKindHasADescription(t *testing.T) {
	for k := tokenKind(0); k < numTokenKinds; k++ {
		if describe(k) == "" {
			t.Errorf("token kind %d unhandled by describe", k)
		}
	}
}

go deeper

for a junior

Know that a switch matching nothing simply runs default, or nothing at all, and that Go will not warn you about a value you forgot to handle.

for a middle

Explain why an unmatched switch leaving a zero value is dangerous, and write a default that panics or returns an error instead of quietly producing a plausible result.

for a senior

Demonstrate the whole defence: a loud default, a sentinel constant with a test that enumerates every value, and a coverage run that proves each clause is exercised — plus what coverage cannot prove.

for a principal

Own the policy for constant sets that cross package boundaries: who may add one, what the release notes must say, whether consumers are expected to fail closed, and whether the set should be exported at all.

## The gap Go has no enumeration type. A set of related constants is just typed integers: ``` type tokenKind int const ( tokIdent tokenKind = iota tokNumber tokString ) ``` A `switch` over `tokenKind` is an ordinary switch over an integer. The compiler has no notion that these three values are "all of them", so it cannot warn that you handled two. `go vet` does not check it either — vet's analyses look for suspicious constructs like printf argument mismatches and lost struct tags, not domain completeness. And there is no compiler flag that changes this. The result is a specific, quiet failure mode. Someone adds `tokComment` to the constant block. Every switch over `tokenKind` in the codebase still compiles. The ones with a `default` take it; the ones without simply fall out and leave the result variable at its zero value — `""`, `0`, `nil`. The program keeps running and produces subtly wrong output, and no tool has said a word. ## Layer one: make the miss loud The first and cheapest defence is to decide what an unmatched value *means* and say so: - If reaching `default` would be a **programmer error** — an internal value that the compiler cannot prove is one of a known set — `panic` with the value in the message. A panic in a unit test is a failed test; a panic in production is a crash that names the exact unhandled constant, which is far better than silent corruption. - If the value can arrive from **outside** the program (a wire format, a config file, a database column), it is not a programmer error, so return an explicit error or a `(value, ok)` pair and force the caller to handle it. What you must not write is `default:` with an empty body or a bare zero return added to quiet a reviewer. It converts a bug into plausible data: a missing case that falls out of the switch already produces the zero value, but a written `default: return ""` looks deliberate, so nobody asks about it again. ## Layer two: a sentinel constant plus an enumerating test The rule you actually want — "adding a constant must force someone to visit every switch" — cannot be expressed to the compiler, but it can be expressed to the test suite. Add a sentinel as the last constant so the count maintains itself, then enumerate: ``` const ( tokIdent tokenKind = iota tokNumber tokString numTokenKinds // sentinel: always last ) func TestEveryKindHasADescription(t *testing.T) { for k := tokenKind(0); k < numTokenKinds; k++ { if describe(k) == "" { t.Errorf("token kind %d unhandled by describe", k) } } } ``` Adding `tokComment` before the sentinel raises `numTokenKinds`, the loop reaches the new value, `describe` returns the zero value or panics, and the test fails with the kind number in the message. That is the closest thing to an exhaustiveness check the language offers, and it costs six lines. It works only because the sentinel is derived from the constant block rather than hand-maintained — a hand-written `const numKinds = 3` is one more thing to forget. Note what this does *not* give you: a compile error. A fixed-size array sized by the sentinel, `[numTokenKinds]string{...}`, catches an index past the end, so it will fail the build if constants are removed or reordered past the array's length, but adding one simply leaves a zero entry. Adding still needs the test. ## Layer three: coverage as the check on the test `go test -coverprofile=cover.out ./...` followed by `go tool cover -html=cover.out` renders every statement your tests never executed. A case body that exists but is never entered shows up red. Be precise about what this proves, because it is a favourite interview follow-up: coverage reports the statements you **wrote**, not the ones you **forgot**. It can never reveal a missing case. What it reveals is the complementary failure — a clause you added and then never exercised, which usually means the enumerating test above is not actually reaching it, or that the case is dead. Treat coverage as an audit of your tests, not as an exhaustiveness tool. ## Layer four, for constants that cross a package boundary If the constant block is exported and other packages switch over it, adding a value is a behavioural change for code you do not compile. Your enumerating test protects your own switches and nothing else. The realistic controls are to document that consumers must have a fail-closed `default`, to note the addition prominently in the release, or — the strongest option — not to export the constants at all and hand out a function that returns the mapping, so there is exactly one switch in the world and it is yours. ## Summary No compiler check, no vet check. Give the switch a `default` that panics or errors instead of returning a plausible zero; derive a sentinel constant from the block and enumerate it in a test so adding a value fails the build; use coverage to confirm every clause you wrote is actually exercised; and for exported constant sets, treat adding one as a contract change.

  • Why is a default clause that returns a zero value worse than no default at all?
    Because it turns a bug into plausible data. A missing case that falls out of the switch already leaves the zero value, but a written `default: return ""` looks deliberate, so reviewers stop asking about it. Decide instead what an unmatched value means: panic if it is a programmer error, or return an explicit error if the value can come from input.
  • Can you get a compile-time failure when someone adds a new constant to the set?
    Not from the switch itself — Go has no exhaustiveness check. A fixed-size array sized by a sentinel constant catches an index past its end, so removing or reordering constants can break the build, but *adding* one just leaves a zero entry. Catching an addition needs the enumerating test that loops to the sentinel and asserts every value maps to something real.
  • What does a coverage run actually tell you about an unhandled case?
    Nothing, if the case was never written — coverage reports statements you wrote, not ones you forgot, so it can never prove exhaustiveness. What it catches is the opposite failure: a clause that exists but no test ever enters, which `go tool cover -html` renders in red. Treat it as an audit of the enumerating test, not as a substitute for it.
  • How does this change when the constants are exported and other packages switch over them?
    Your test only guards your own switches; adding a value silently changes behaviour in code you do not compile. Either document that consumers must have a fail-closed default and flag the addition in the release notes, or keep the constants unexported and expose a function that returns the mapping — then exactly one switch exists and you own it.

saying these in an interview costs you the question

  • Assumes the compiler rejects a switch missing a constant
  • Says go vet reports non-exhaustive switches
  • Adds an empty default clause to silence review
  • Returns a zero value when nothing matched
  • Treats coverage as proof that no case is missing
  • Relies on code review alone to catch a new constant