8. Pattern Matching and Enums
This is a Rust enums and pattern matching guide for experienced TypeScript developers.
As a TS developer you may have a love-hate relationship with TS enum: it’s a runtime object, sometimes compiled to an IIFE or numbers. In practice, the TypeScript community prefers discriminated unions: type State = { kind: 'A' } | { kind: 'B', payload: string }.
Rust enums are the full evolution of TS discriminated unions, and together with pattern matching they form the backbone of Rust’s type system.
The crown of the type system: Rust enums and pattern matching
1. Concept alignment: this isn’t the enum you know
In C++ or Java, enums are named integers. In TypeScript they’re string or number mappings. In Rust, enums are Algebraic Data Types (ADTs).
1.1 Carrying data
In TS you’d write:
TypeScript (discriminated union):
1
2
3
4
5
6
7
8
9
10
11
type Message =
| { kind: "Quit" }
| { kind: "Move"; x: number; y: number }
| { kind: "Write"; content: string }
| { kind: "ChangeColor"; r: number; g: number; b: number };
function handle(msg: Message) {
if (msg.kind === "Move") {
console.log(msg.x, msg.y); // TS narrows the type
}
}
Rust (enum): Rust fuses the “tag” and the “payload” into one type.
1
2
3
4
5
6
7
enum Message {
Quit, // No data
Move { x: i32, y: i32 }, // Anonymous struct
Write(String), // Tuple
ChangeColor(i32, i32, i32), // Tuple of three i32
}
Mental model: The enum definition doesn’t just define values; it defines the shape of the data. The String in Write(String) isn’t a separate heap object; it’s embedded in the enum’s layout.
2. Memory layout: how Rust stores enums
- TypeScript: Objects are heap hash maps.
{ kind: 'Move', x: 1, y: 2 }stores property names (or Hidden Class pointer),kind,x,y. - Rust: Tagged union layout.
- Discriminant (tag): A small integer (often 1 byte) indicating which variant (Quit=0, Move=1, …).
- Payload (union): Right after the tag; size is the largest variant.
- Padding: For alignment.
Example: If Quit has 0 bytes, Write(String) has 24 bytes (String = ptr+cap+len), Move has 8 bytes, then each Message might be about 32 bytes on the stack (tag + padding + max payload).
Conclusion: Rust enum access is fast: contiguous stack memory, no property lookup.
3. Option: beyond the “billion dollar mistake”
TS has strict null checks, but null and undefined still exist as special values. Rust removes null and uses a standard enum:
1
2
3
4
5
enum Option<T> {
None,
Some(T),
}
- TS:
let x: string | null = null;— you must rememberif (x). - Rust:
let x: Option<String> = None;— you cannot usexas a String directly. The compiler forces you to unwrap or handleNone.
That forces handling of absence at compile time and avoids runtime “Cannot read property of undefined”.
4. match: switch on steroids
TS switch is value equality (===) and easy to forget break. Rust match is pattern matching and is an expression.
4.1 Exhaustiveness
1
2
3
4
5
6
7
8
9
fn process(msg: Message) {
match msg {
Message::Quit => println!("Quit"),
Message::Move { x, y } => println!("Move to {}, {}", x, y),
Message::Write(text) => println!("Text: {}", text),
// ❌ Compile error: missing `ChangeColor` branch
}
}
TS comparison: In TS you might use default: assertNever(msg) to catch missing cases. In Rust, exhaustiveness is default.
4.2 Destructuring in patterns
Message::Move { x, y } binds x and y directly; cleaner than case 'Move': const {x,y} = msg; in TS.
4.3 Match guards
You can add extra conditions:
1
2
3
4
5
6
match msg {
Message::Move { x, y } if x > 100 => println!("Big move!"),
Message::Move { x, y } => println!("Normal move"),
_ => (), // _ is wildcard, like default
}
4.4 Binding (@)
Sometimes you want to test a value and also bind it to a name:
1
2
3
4
5
6
7
match msg {
Message::Move { x: id_variable @ 3..=7, y } => {
println!("Found an id in range: {}", id_variable)
},
_ => (),
}
5. if let: sugar for one variant
When you only care about one variant and want to ignore the rest, match is verbose.
TS:
1
2
3
if (msg.kind === "Write") {
console.log(msg.content);
}
Rust (match):
1
2
3
4
5
match msg {
Message::Write(text) => println!("{}", text),
_ => (), // Boilerplate
}
Rust (if let):
1
2
3
4
if let Message::Write(text) = msg {
println!("{}", text);
}
if let takes a pattern and an expression. You give up exhaustiveness for brevity.
6. Example: from TS to Rust
Design an HTTP request state machine.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
type RequestState =
| { status: "Idle" }
| { status: "Loading" }
| { status: "Success"; data: string }
| { status: "Error"; error: Error };
function render(state: RequestState) {
if (state.status === "Success") {
return `Data: ${state.data}`;
}
// ...
}
Rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum RequestState {
Idle,
Loading,
Success(String),
Error(String),
}
fn render(state: RequestState) -> String {
match state {
RequestState::Idle => String::from("Idle"),
RequestState::Loading => String::from("Loading..."),
RequestState::Success(data) => format!("Data: {}", data),
RequestState::Error(err) => format!("Error: {}", err),
}
}
Important: When you match state, if state holds heap data (e.g. Success(String)), ownership moves into the match arm. To only read and not consume, match on a reference:
1
2
3
4
5
6
7
fn render(state: &RequestState) {
match state {
RequestState::Success(s) => println!("Data: {}", s),
_ => (),
}
}
7. Summary
For TS developers, the key points are:
- Forget TS enum: Rust enums are supercharged discriminated unions, not integer mappings.
- Embrace pattern matching: Use
matchto destructure and branch; avoid ad-hoc property checks. - Get used to Option/Result: No null, no throw; only
NoneandErr. That changes how you handle errors. - Watch ownership: Matching an enum can move data. To avoid that, match on
&Enum.
Rust enums + match don’t just tidy code; when you add a new variant, the compiler tells you exactly where to update, enabling fearless refactoring.