Viscosity
Review
Part IX · Rust for TypeScript developers · Day 32

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.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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].

Definition

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:

Step throughA value falling through match arms
let n = match v {
Value::Nil => 0.0,
Value::Bool(true) => 1.0,
Value::Bool(false) => 0.0,
Value::Number(x) => x,
};
The value being matched, drawn as a variant and a payload, compared with the pattern of the arm being triedVALUE vPATTERNNumber2.5
v holds Value::Number(2.5): the variant Number, carrying the payload 2.5. The match will try its arms from the top.
1/8

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].

Definition

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, 0 or the byte b'('.
  • Variants, like Value::Nil, with patterns for their data inside.
  • Bindings: a plain name like x fits anything and names it.
  • The wildcard _ fits anything and names nothing.
  • Or-patterns: p | q fits 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.

Pitfall

_ 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.

Try itDrip values as an enum
#[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));
}
Open in Rust Playground ↗
Definition

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.

Under the hood

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

ExerciseA five-instruction VM

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]
}
Open in Rust Playground ↗
Extra practiceDrip’s ==, types first

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
}
Open in Rust Playground ↗

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.
  • match tries 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 match on OpCode.

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. 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. 2.

    Your enum Op gets a new variant, Div. What happens to a match op that lists every other variant and has no _ arm?

  3. 3.

    Which TypeScript type is closest to enum Value { Nil, Bool(bool), Number(f64) }?

  4. 4.

    In Value::Number(x) => x * 2.0, what kind of pattern is x?

  5. 5.

    Why is a match with a _ => {} arm riskier than one listing every variant?

  6. 6.

    The Rust VM has enum ObjUpvalue { Open(usize), Closed(Value) }. What does this rule out?

References

  1. [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. [2] The Rust Reference contributors. match expressions. The Rust Reference. doc.rust-lang.org/reference/expressions/match-expr.html (checked 2026-10-31)
  3. [3] The Rust Reference contributors. Enumerations. The Rust Reference. doc.rust-lang.org/reference/items/enumerations.html (checked 2026-10-31)