Tagged Unions, Zero Style
choice declares a type whose value is one of several named variants, each variant carrying its own payload:
choice Result {
ok: i32,
err: String,
}
A Result value is either an ok carrying an i32 or an err carrying a String. Never both, never neither. The type system makes that guarantee, and match makes it convenient to act on.
This is the same idea other languages call "tagged union", "sum type", "discriminated union", or "algebraic data type". Zero spells it choice and keeps the grammar small.
Declaring a Choice
choice Name {
variantA: PayloadTypeA,
variantB: PayloadTypeB,
}
Each line lists one variant. The name is yours; the type after the colon is the payload that variant carries. A variant that doesn't need a payload uses Void:
choice Token {
word: String,
number: i32,
eof: Void,
}
Token.eof is a variant with no useful payload (its payload type is Void) — useful for terminator-style cases.
Constructing a Choice Value
Build a value by naming the type, then the variant, then passing the payload:
let success = Result.ok(42)
let failure = Result.err("validation failed")
The payload type has to match the variant's declared payload. Result.ok("hello") would be a compile error because ok expects an i32.
Type inference works here too. If the right-hand side fully determines the type, you can write let success = Result.ok(42) and the binding's type is Result. Annotating the type explicitly is fine when you want it documented at the binding site:
let success: Result = Result.ok(42)
Matching a Choice
match is how you read a choice value. The shape:
match value {
.variantA => binding { /* body when value is variantA, with payload in `binding` */ }
.variantB => binding { /* body when value is variantB */ }
}
A worked example from the official Zero repo — click Run to see the .ok arm fire:
Read the match literally: "depending on which variant result holds, run the matching arm and bind the payload to the chosen name." In the .ok arm, value is the i32 payload. In the .err arm, message is the String payload. Each arm is a separate scope; the binding is only visible inside its own body.
Exhaustiveness
This is the big payoff of match over if/else if chains: the compiler verifies that every variant has an arm. If you forget the .err case, you don't get a runtime fall-through to a default branch — you get a compile error:
{
"code": "MAT001",
"message": "match is not exhaustive: missing variant 'err'",
"line": 9
}
(Error code is illustrative; the principle is the contract.)
Add a new variant to the choice — say Result.timeout: Void — and every match against Result in the codebase becomes a compile error until you handle the new case. That's a feature, not a chore: the compiler is telling you exactly where the new case needs attention.
When You Don't Need the Payload
If a variant's payload is Void or you simply don't care about it in this arm, you can ignore the binding — but you still have to write the arm to satisfy exhaustiveness:
match token {
.word => w { /* use w */ }
.number => n { /* use n */ }
.eof => _ { /* nothing to bind */ }
}
The exact spelling for "ignore the payload" may evolve in pre-1.0 Zero (you may see _ or simply omit the binding). The conceptual point — every variant gets an arm, payload-or-not — is the stable bit.
Common Patterns
A Result-style error type
This is exactly the example the official repo uses:
choice Result {
ok: i32,
err: String,
}
Functions that can succeed-with-value or fail-with-message return a Result. Callers pattern-match to extract either the value or the message. Zero's raises/check system handles propagation for fallible operations; Result is useful when you want to hold onto a success-or-failure value as data.
A parser token
choice Token {
word: String,
number: i32,
eof: Void,
}
A tokenizer produces a stream of Tokens. Each consumer matches on the variant to decide what to do — print the word, sum the number, exit on eof.
A state machine
choice State {
waiting: Void,
processing: i32,
done: String,
}
processing carries the current task ID; done carries the final result. Each transition is a new State value — no mutable fields scattered through a shape.
Choice + Generics
choice can be generic just like shape:
choice Maybe<T> {
some: T,
none: Void,
}
Maybe<i32> is "an optional integer". Maybe<String> is "an optional string". This same pattern shows up in the Zero standard library and is a much better fit than a null sentinel value — there's no way to forget the .none case once you match against the type.
When to Use Choice vs. Shape vs. Enum
Quick recap from shapes and enums:
- Shape — record with multiple fields, all present together.
- Enum — one of N labels, no extra data.
- Choice — one of N variants, each carrying a payload.
Most data models in a real program are some combination of these three. The clarity of starting from "is this and, or, or or with data?" is one of the underrated benefits of working in a small language.
Next: World Capability
choice and match cover Zero's data side. The next chapter is about effects — how Zero programs interact with the outside world. It starts with the World capability, the object that gates every piece of I/O.
Frequently Asked Questions
What is a choice in Zero?
A choice is Zero's tagged-union type — a value that is one of several named variants, each variant carrying its own payload type. Example: choice Result { ok: i32, err: String }. A Result value is either an ok carrying an i32 or an err carrying a String. You construct one with Result.ok(42) or Result.err("bad").
How does match work in Zero?
match value { .variantA => binding { ...body } .variantB => binding { ...body } } branches on which variant value holds. Each arm pattern-matches a variant, names the payload binding, and executes its body. The compiler verifies you've covered every variant — exhaustiveness is the headline benefit over if/else if.
How do you build a choice value?
Construct it by naming the type and variant and passing the payload: let r: Result = Result.ok(42) or let r = Result.err("validation failed"). The payload type has to match the variant's declared payload — passing the wrong type is a compile error.
What's the difference between choice and enum?
enum variants are just labels with no payload. choice variants each carry a value of a declared type. If you need to attach data to one of the cases (an error message, a successful result, a parsed token), use choice. If the cases are pure labels, use enum.
Why is match preferred over if-else for choices?
match is exhaustive by construction — the compiler checks that every variant is handled, so adding a new variant later forces you to update every site that branches on the type. An if/else if chain silently falls through, hiding the missing case until it shows up as a bug in production.