128. Enums and match
Your TypeScript VM spelled “one of several things” three different ways. Rust has one way, and the compiler checks that you handled every case.
- Define an enum whose variants carry data, and build values of it.
- Use match to branch on an enum, and explain why it must be exhaustive.
- Write patterns with literals, bindings, _, | and pairs.
- Read the opcode match at the heart of the Rust Drip VM.
One of several things
Your TypeScript VM needed “this is one of several things” in three places, and spelled it three ways. The dispatch loop switched on opcode numbers:
switch (instruction) {
case OP.NIL: this.push(null); break;
case OP.NEGATE: /* ... */ break;
// ...
}
A value was null | boolean | number | Obj, checked with typeof
(chapter 49). And when you wanted a tag and a payload, you wrote a
discriminated union (chapter 14):
type Tagged =
| { tag: 'nil' }
| { tag: 'bool'; as: boolean }
| { tag: 'num'; as: number };
Rust has a single feature for all three.
A parcel label with tick boxes: letter, parcel, registered. You tick exactly one. Some boxes come with a blank to fill in, like “registered: tracking number ____”, and the blank only exists if you ticked that box.
Enums
enum Op {
Constant,
Add,
Negate,
Return,
}
enum Value {
Nil,
Bool(bool),
Number(f64),
}
let op = Op::Add;
let v = Value::Number(2.5);
These are adapted from the Rust Drip VM. Its OpCode (in
companion/rust/src/chunk.rs) has 37 variants, and its Value (in
value.rs) adds variants for strings, functions, classes and other
heap objects.
An Op is exactly one of four things. A Value is one of three, and
two of those carry data: Bool holds a bool, Number holds an f64.
That is the tagged value of chapter 49, written as a type. A variant
can also hold several values, Pair(f64, f64), or named fields like a
struct, Move { dx: f64, dy: f64 }[1].
An enum is a type whose every value is exactly one of a fixed list
of variants. A variant may carry data. You name one with a path,
Value::Number(2.5). An enum with data is Rust’s version of a
discriminated union: the variant is the tag, its data is the payload.
match
To do anything with an enum you ask which variant it is, and take its
data out. That is match:
let n = match v {
Value::Nil => 0.0,
Value::Bool(true) => 1.0,
Value::Bool(false) => 0.0,
Value::Number(x) => x,
};
Each line inside is an arm: a pattern, =>, and the code to run.
Step through what happens to two different values:
let n = match v {Value::Nil => 0.0,Value::Bool(true) => 1.0,Value::Bool(false) => 0.0,Value::Number(x) => x,};
Three things to notice. Arms are tried top to bottom, and the first one
that fits wins. There is no falling through to the next arm, so there
is no break to forget. And match is an expression, like if in
chapter 123, so every arm produces a value of the same type.
The third is the big one. A match must cover every possible value.
Leave out Value::Bool(false) and you get this (real rustc 1.97
output, trimmed):
error[E0004]: non-exhaustive patterns:
`Value::Bool(false)` not covered
In TypeScript you can get the same check, but you have to set it up
with a never trick (chapter 14). In Rust it is always on, and it is a
hard error, not a warning[1].
A match expression compares a value with the patterns of its
arms, in order, and runs the first arm that fits[2].
The value and the patterns must have the same type. The match must be
exhaustive: every possible value has to fit some arm, or it doesn’t
compile.
Patterns
The left side of an arm is a pattern: a shape the value is compared with. You have now seen several kinds:
- Literals, like
true,0or the byteb'('. - Variants, like
Value::Nil, with patterns for their data inside. - Bindings: a plain name like
xfits anything and names it. - The wildcard
_fits anything and names nothing. - Or-patterns:
p | qfits if either fits. - Pairs:
(p, q)matches two values at once.
Here is Drip’s truthiness from chapter 35, with an or-pattern:
fn is_falsey(v: Value) -> bool {
match v {
Value::Nil
| Value::Bool(false) => true,
_ => false,
}
}
and here is + on two numbers, matching a pair. (a, b) is a
tuple: a few values grouped in parentheses.
match (a, b) {
(
Value::Number(x),
Value::Number(y),
) => Value::Number(x + y),
_ => Value::Nil, // VM: an error
}
The Rust scanner matches on bytes the same way, one arm per character,
like b'(' => self.make(T::LeftParen), and ends with _ for anything
it doesn’t recognise.
_ is convenient and dangerous. It covers every case you haven’t
written, including ones added later. Add a variant to Op and a match
with _ => {} keeps compiling, quietly doing nothing for it. Use _ when
“everything else” truly means one answer, as in is_falsey, and list
the variants when each one deserves a thought.
#[derive(Clone, Copy)]
enum Value {
Nil,
Bool(bool),
Number(f64),
}
fn is_falsey(v: Value) -> bool {
match v {
Value::Nil
| Value::Bool(false) => true,
_ => false,
}
}
fn add(a: Value, b: Value) -> Value {
match (a, b) {
(
Value::Number(x),
Value::Number(y),
) => Value::Number(x + y),
_ => Value::Nil,
}
}
fn show(v: Value) -> String {
match v {
Value::Nil => {
String::from("nil")
}
Value::Bool(b) => b.to_string(),
Value::Number(n) => {
n.to_string()
}
}
}
fn main() {
let two = Value::Number(2.0);
let t = Value::Bool(true);
println!("{}", show(add(two, two)));
println!("{}", show(add(two, t)));
let nil = Value::Nil;
println!("{}", is_falsey(nil));
println!("{}", is_falsey(two));
}A pattern describes a shape a value can have: literals, enum
variants, names that bind parts of the value, the wildcard _,
alternatives p | q, and tuples of patterns. A binding is a new
variable that exists only inside its arm.
The heart of the Rust Drip VM is one match in run, in
companion/rust/src/vm.rs, and it reads like your TypeScript switch:
match op {
OpCode::Nil => self.push(Value::Nil),
OpCode::Add => self.add()?,
OpCode::Negate => { /* ... */ }
// ...one arm per opcode
}The real one lists all 37 opcodes and has no _ arm, so adding an
opcode breaks the build until the VM handles it. OpCode is marked
#[repr(u8)], which makes each variant exactly the byte that encodes it,
numbered to match the TypeScript VM’s OP table.
Underneath, every enum value has a discriminant: an integer that says which variant it holds. The compiler is free to store it in a smaller type, or to tell variants apart some other way[3]. Either way, it is chapter 49’s tag and payload, and you never write the tag by hand.
Enums also make bad states impossible to build. Chapter 57’s upvalues
are open (pointing at a stack slot) or closed (holding the value
itself). The Rust VM says exactly that, enum ObjUpvalue { Open(usize), Closed(Value) }, so a closed upvalue that still has a slot can’t exist.
Closing one replaces the whole value: ObjUpvalue::Closed(...).
Your turn
Finish eval, a tiny stack VM in the style of the Rust Drip VM’s run loop. Push(v) pushes v. Add, Sub and Mul pop the top two values and push the result, with the lower value on the left (Push(10.0), Push(4.0), Sub gives 6). Neg replaces the top value with its negation. Return the value on top at the end. Replace the _ => todo!() arm with real arms, and don’t keep a _ arm.
enum Op {
Push(f64),
Add,
Sub,
Mul,
Neg,
}
fn eval(code: &[Op]) -> f64 {
let mut st = [0.0; 16];
let mut top = 0;
let mut i = 0;
while i < code.len() {
match code[i] {
Op::Push(v) => {
st[top] = v;
top += 1;
}
_ => todo!(),
}
i += 1;
}
st[top - 1]
}Write equal(a, b) with Drip’s rules from chapter 49: values of different variants are never equal; two Nils are equal; booleans and numbers compare their payloads with ==. Match on the pair (a, b).
#[derive(Clone, Copy)]
enum Value {
Nil,
Bool(bool),
Number(f64),
}
fn equal(a: Value, b: Value) -> bool {
false
}What you learned
- An enum is one of a fixed set of variants, which can carry data. It is a discriminated union built into the language.
matchtries its arms in order and runs the first that fits. It is an expression, and it must be exhaustive.- Patterns are shapes: literals, variants, bindings,
_,|and tuples. Bindings pull the payload out. - A wildcard covers cases you haven’t thought of yet. Prefer listing variants when each needs its own answer.
- The Rust VM’s dispatch loop is a single exhaustive
matchonOpCode.
That’s the end of Day 32. Next: two enums so useful they are built into
the standard library, Option and Result, and how Rust handles errors
without exceptions.
Check your understanding
0/6- 1.
What does this print?
let v = Value::Bool(false); let n = match v { Value::Bool(true) => 1, Value::Bool(_) => 2, _ => 3, }; println!("{n}"); - 2.
Your
enum Opgets a new variant,Div. What happens to amatch opthat lists every other variant and has no_arm? - 3.
Which TypeScript type is closest to
enum Value { Nil, Bool(bool), Number(f64) }? - 4.
In
Value::Number(x) => x * 2.0, what kind of pattern isx? - 5.
Why is a
matchwith a_ => {}arm riskier than one listing every variant? - 6.
The Rust VM has
enum ObjUpvalue { Open(usize), Closed(Value) }. What does this rule out?
References
- [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
- [2] The Rust Reference contributors. match expressions. The Rust Reference. doc.rust-lang.org/reference/expressions/match-expr.html (checked 2026-10-31)
- [3] The Rust Reference contributors. Enumerations. The Rust Reference. doc.rust-lang.org/reference/items/enumerations.html (checked 2026-10-31)