Skip to content
>_rs4ts

Pattern Matching

25 min readupdated byAhmet ZeybekAhmet Zeybek· Jul 10, 2026

Pattern matching is the feature most TypeScript/JavaScript developers come to miss the moment they go back. Rust’s match is array/object destructuring, a switch statement, type narrowing, and an exhaustiveness checker all rolled into one expression, and the compiler refuses to let you forget a case.


A pattern describes the shape of a value: a literal, a range, a tuple, a struct, an enum variant, or a combination of these. Rust lets you match a value against patterns in many places: most prominently the match expression, but also let, if let, let ... else, while let, and function parameters. The defining feature is exhaustiveness: a match must cover every possible value, so the compiler turns “I forgot the null case” from a 3 a.m. production incident into a build error.

Note: This file focuses on the patterns themselves and the places they appear: match, let, if let, let else, while let, and the pattern syntax for tuples, structs, enums, ranges, guards, and bindings. The data types you match on each have their own file: structs, enums, and Option<T>. Method syntax lives in impl blocks.


TypeScript has no real pattern matching. You reach for a combination of destructuring, switch, and manual if/else chains on a discriminant field, and nothing forces you to handle every case.

// A discriminated union — the closest TS analogue to a Rust enum.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
// With this explicit `: number` return type, TS DOES flag a missing case
// here (error TS2366: function lacks ending return statement) — no `never`
// trick required. The `never`-assignment exhaustiveness trick is only needed
// for side-effect (void) switches, where TS will NOT catch a missing case.
}
}
// Destructuring is separate from "matching":
const point = { x: 3, y: 7 };
const { x, y } = point; // object destructuring
const [first, , third] = [1, 2, 3]; // array destructuring, skip middle
// Narrowing a value still needs hand-written conditionals:
function classifyStatus(code: number): string {
if (code === 200) return "OK";
if (code === 301 || code === 302) return "Redirect";
if (code >= 400 && code <= 499) return "Client error";
if (code >= 500 && code <= 599) return "Server error";
return "Other";
}

In TypeScript, destructuring (pulling values out) and matching (deciding which branch) are two unrelated tools. Exhaustiveness checking is partial: for a value-returning switch with a declared or inferred return type, TypeScript flags a missing case automatically: either at the function (error TS2366, the explicit-return-type case shown above) or at the call site (the inferred-number | undefined case, error TS2322). But it relies on return-type flow analysis, so a side-effect (void) switch will silently miss a case unless you add the never-assignment trick by hand. Rust, as we will see, enforces exhaustiveness structurally for every match, no return type or assert needed.


In Rust, destructuring and matching are the same mechanism. A pattern simultaneously tests a value’s shape and binds its parts to names. And match is exhaustive by construction.

#[derive(Debug)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
fn area(shape: &Shape) -> f64 {
// Every variant must be handled, or this will not compile.
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
fn main() {
// `let` itself takes a pattern — this is destructuring.
let (name, age) = ("Alice", 30);
println!("{name} is {age}");
// Struct destructuring with a `let` pattern.
#[derive(Debug)]
struct Point { x: i32, y: i32 }
let point = Point { x: 3, y: 7 };
let Point { x, y } = point;
println!("x={x}, y={y}");
let shapes = [
Shape::Circle { radius: 2.0 },
Shape::Rectangle { width: 3.0, height: 4.0 },
Shape::Triangle { base: 6.0, height: 2.0 },
];
for s in &shapes {
println!("{s:?} -> area {:.2}", area(s));
}
}

Output (real, from cargo run):

Alice is 30
x=3, y=7
Circle { radius: 2.0 } -> area 12.57
Rectangle { width: 3.0, height: 4.0 } -> area 12.00
Triangle { base: 6.0, height: 2.0 } -> area 6.00

Note: The f64 literals print as 2.0, not 2, because Rust’s Debug formatting for floats always shows the decimal point. This is unlike JavaScript, where console.log(2.0) prints 2.


A JavaScript switch is a statement: it runs side effects, and you return or break out. A Rust match is an expression: it evaluates to a value you can assign or return directly. Each arm is pattern => expression.

fn main() {
let score = 84;
let grade = match score {
90..=100 => 'A',
80..=89 => 'B',
70..=79 => 'C',
_ => 'F',
};
println!("grade = {grade}"); // grade = B
}

Because match is an expression, there is no fall-through and no break. Exactly one arm runs, and its value becomes the value of the whole match. (Compare to switch, where a forgotten break causes accidental fall-through, a class of bug that simply cannot exist here.)

Each arm’s left side is a pattern. When the pattern matches, any names inside it are bound to the corresponding pieces of the value. In Shape::Rectangle { width, height }, the arm matches only Rectangle values, and on a match it binds width and height to that rectangle’s fields. There is no separate “destructure” step.

Patterns can be concrete values, inclusive ranges (..=), or alternatives joined with |:

fn describe_status(code: u16) -> &'static str {
match code {
200 => "OK",
301 | 302 | 307 | 308 => "Redirect", // or-pattern
400..=499 => "Client error", // inclusive range
500..=599 => "Server error",
_ => "Other", // wildcard catch-all
}
}
fn main() {
for code in [200u16, 302, 404, 503, 100] {
println!("{code} -> {}", describe_status(code));
}
}

Output (real):

200 -> OK
302 -> Redirect
404 -> Client error
503 -> Server error
100 -> Other

The _ wildcard matches anything and binds nothing. It is the equivalent of a default: case, and it is how you satisfy exhaustiveness for the “everything else” bucket.

A pattern can be followed by if <condition>, called a match guard. The arm matches only if the pattern fits and the guard is true.

#[derive(Debug)]
struct Point { x: i32, y: i32 }
fn classify(point: Point) -> &'static str {
match point {
Point { x: 0, y: 0 } => "origin",
Point { x: 0, y } if y > 0 => "north axis",
Point { x: 0, y: _ } => "south axis",
Point { x, y: 0 } if x != 0 => "horizontal axis",
Point { .. } => "somewhere else",
}
}

Here Point { x: 0, y } matches any point on the y-axis and binds the y-coordinate; the guard if y > 0 then narrows it further. The Point { .. } pattern uses .. to ignore all remaining fields.

Sometimes you want to test a value against a range and also keep the value. The @ operator binds a name to a value while also pattern-matching it:

fn main() {
let id = 42;
match id {
n @ 1..=50 => println!("{n} is in the low range"),
n @ 51..=100 => println!("{n} is in the high range"),
n => println!("{n} is out of range"),
}
// 42 is in the low range
}

Without @, the range pattern 1..=50 would match but give you no name for the matched value; with n @ 1..=50 you get both.

Reference patterns: matching without moving

Section titled “Reference patterns: matching without moving”

This is the part that trips up TypeScript developers, because JavaScript has no concept of ownership. When you match a value by reference (match &thing), the bindings inside the pattern are also references, so the original value is not consumed:

fn main() {
let owned = String::from("config.toml");
let maybe = Some(owned);
// Matching `&maybe` borrows; `path` is a `&String`, not a moved String.
match &maybe {
Some(path) => println!("path is {path}"),
None => println!("no path"),
}
// We can still use `maybe` afterwards because nothing was moved out.
println!("still own it: {maybe:?}");
}

Output (real):

path is config.toml
still own it: Some("config.toml")

Modern Rust applies match ergonomics: when you match a reference (&maybe) against a non-reference pattern (Some(path)), the compiler automatically makes the inner bindings references for you. You rarely need to write the old ref keyword anymore. (See Ownership for the underlying move/borrow rules.)

Patterns nest arbitrarily, mirroring the structure of the data.

fn main() {
// Tuple destructuring, including nested tuples.
let record = ("Bob", (98.5, 87.0), true);
let (name, (math, science), active) = record;
println!("{name}: math={math}, science={science}, active={active}");
// `..` ignores the middle of a tuple.
let numbers = (1, 2, 3, 4, 5);
let (first, .., last) = numbers;
println!("first={first}, last={last}");
// Slice patterns match on the *shape* of a slice.
let parts: &[&str] = &["GET", "/index.html", "HTTP/1.1"];
match parts {
[method, path, version] => println!("{method} {path} ({version})"),
[method, path] => println!("{method} {path}"),
[] => println!("empty request line"),
_ => println!("unexpected request line"),
}
// Bind the head and capture the rest with `tail @ ..`.
let words = vec!["a", "b", "c", "d"];
if let [head, tail @ ..] = words.as_slice() {
println!("head={head}, tail={tail:?}");
}
}

Output (real):

Bob: math=98.5, science=87, active=true
first=1, last=5
GET /index.html (HTTP/1.1)
head=a, tail=["b", "c", "d"]

Slice patterns ([head, tail @ ..]) are the closest Rust equivalent to JavaScript’s array destructuring with rest (const [head, ...tail] = arr), but they also let you match on length, something JavaScript destructuring cannot express.

match is the heavyweight, but four lighter forms cover the common cases:

fn main() {
// 1. `if let` — match one pattern, ignore the rest. Like a `match` with
// one interesting arm. Equivalent in spirit to TS `if (x !== undefined)`.
let config: Option<u16> = Some(8080);
if let Some(port) = config {
println!("server on port {port}");
} else {
println!("using default port");
}
// 2. `let ... else` — bind on the happy path, or diverge (return/break/panic).
// Great for "early return on failure" without nesting.
let raw = "127";
let Ok(n) = raw.parse::<i32>() else {
println!("not a number");
return;
};
println!("parsed {n}"); // n is in scope for the rest of the function
// 3. `while let` — loop as long as the pattern keeps matching.
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("popped {top}");
}
}

Output (real):

server on port 8080
parsed 127
popped 3
popped 2
popped 1

The fourth form is the matches! macro, which returns a bool for “does this value match this pattern?”, handy in conditions and .filter() closures:

#[allow(dead_code)] // `Click` is never constructed in this isolated snippet
#[derive(Debug)]
enum Event { Click { x: i64, y: i64 }, Close }
fn main() {
let e = Event::Close;
println!("is close? {}", matches!(e, Event::Close)); // is close? true
}

A match must cover every possible value of the type. This is enforced structurally, at compile time, for every match — independent of whether the match returns a value or what that value’s type is. If you add a variant to an enum later, every match that does not handle it stops compiling, and the compiler hands you a to-do list of every place you need to update. TypeScript does catch missing cases in a value-returning switch (via return-type flow analysis, as shown earlier), but that safety net disappears for side-effect (void) switches unless you hand-write a never-assignment assert. Rust needs no such opt-in: exhaustiveness is part of what match is.


ConceptTypeScript/JavaScriptRust
Branching constructswitch statement (with fall-through)match expression (no fall-through)
Returns a valueNo (statement)Yes (expression)
DestructuringSeparate from branchingSame mechanism as branching
ExhaustivenessAutomatic for a value-returning switch (return-type flow analysis); needs a never assert for void switchesMandatory and structural for every match
Match on rangesManual if (x >= a && x <= b)a..=b pattern
Match on multiple valuescase a: case b: fall-througha | b or-pattern
Conditions in a branchif inside the case bodyMatch guard if cond on the arm
Bind while testingNot availablename @ pattern
Slice/array length matchNot expressible by destructuring[a, b, c] slice patterns
Moves a matched value?N/A (no ownership)Depends: match by &value to borrow

Why exhaustiveness instead of a silent default?

Section titled “Why exhaustiveness instead of a silent default?”

Rust’s reasoning: a missing case is almost always a bug, and a bug the compiler can see is a bug it should refuse to ship. The _ wildcard is always available when “everything else” is genuinely intended, but you have to write it on purpose, making the choice explicit rather than accidental. TypeScript gets you partway there: a value-returning switch with a known return type is checked, but a void switch is not unless you opt in with the never trick. Rust closes that gap — exhaustiveness applies to every match regardless of context, so you opt out (with _) rather than opt in to safety.

A pattern that can fail to match is refutable (Some(x)); one that always matches is irrefutable ((a, b), Point { x, y }). let and function parameters require irrefutable patterns: let Some(x) = opt; is a compile error because it might fail. That is exactly why if let, let else, and while let exist: they are the constructs that allow refutable patterns by giving a fallback path.


Pitfall 1: Forgetting a case (which is the point)

Section titled “Pitfall 1: Forgetting a case (which is the point)”
enum Direction { North, South, East, West }
fn label(d: Direction) -> &'static str {
// does not compile (error[E0004]: non-exhaustive patterns: `Direction::West` not covered)
match d {
Direction::North => "up",
Direction::South => "down",
Direction::East => "right",
// forgot West!
}
}

The real compiler error:

error[E0004]: non-exhaustive patterns: `Direction::West` not covered
--> src/main.rs:4:11
|
4 | match d {
| ^ pattern `Direction::West` not covered
|
note: `Direction` defined here
--> src/main.rs:1:6
|
1 | enum Direction { North, South, East, West }
| ^^^^^^^^^ ---- not covered
= note: the matched value is of type `Direction`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
7 ~ Direction::East => "right",
8 ~ Direction::West => todo!(),
|

This is not a problem to “fix and forget” — it is the feature working. Add the missing arm. Reach for _ => ... only when you truly want a catch-all, and prefer naming variants explicitly so that adding a variant later forces you to revisit the match.

Pitfall 2: A catch-all arm placed too early

Section titled “Pitfall 2: A catch-all arm placed too early”

Arms are tried top to bottom, so a _ (or any irrefutable pattern) before more specific arms makes those later arms dead code.

fn describe(n: i32) -> &'static str {
match n {
_ => "anything",
0 => "zero", // unreachable
}
}

The real compiler warning (this compiles, but warns):

warning: unreachable pattern
--> src/main.rs:4:9
|
3 | _ => "anything",
| - matches any value
4 | 0 => "zero",
| ^ no value can reach this
|
= note: `#[warn(unreachable_patterns)]` on by default

Put specific patterns first and the wildcard last.

Pitfall 3: Matching against a variable instead of comparing to it

Section titled “Pitfall 3: Matching against a variable instead of comparing to it”

This is the trap that surprises every TypeScript developer. In a switch, case expected: compares against the value of expected. In a Rust match, a bare lowercase identifier is a new binding that matches anything — it does not compare against an existing variable.

fn main() {
let expected = 3;
let value = 7;
match value {
expected => println!("matched expected!"), // binds a NEW `expected`, always matches
_ => println!("did not match"),
}
}

The compiler warns loudly, and even explains the underlying rule:

warning: unreachable pattern
--> src/main.rs:6:9
|
5 | expected => println!("matched expected!"), // BUG: binds, doesn't compare
| -------- matches any value
6 | _ => println!("did not match"),
| ^ no value can reach this
|
note: there is a binding of the same name; if you meant to pattern match against the value of that binding, that is a feature of constants that is not available for `let` bindings
--> src/main.rs:2:9
|
2 | let expected = 3;
| ^^^^^^^^
= note: `#[warn(unreachable_patterns)]` on by default

Fix — use a match guard, or match against a const (which does compare, because const names in patterns are treated as values):

const EXPECTED: i32 = 3;
fn main() {
let expected = 3;
let value = 3;
// Option A: a guard compares against the variable.
match value {
v if v == expected => println!("guard matched"),
_ => println!("no match"),
}
// Option B: match against a const (UPPER_SNAKE_CASE) — this DOES compare.
match value {
EXPECTED => println!("const matched"),
_ => println!("no match"),
}
}

Output (real):

guard matched
const matched

Warning: This is why Rust’s naming convention matters in patterns. lowercase = a fresh binding (matches anything); UPPER_SNAKE_CASE (a const) = a value comparison. The compiler relies on this casing distinction.

Pitfall 4: Using a refutable pattern in let

Section titled “Pitfall 4: Using a refutable pattern in let”
fn main() {
let opt: Option<i32> = Some(5);
// does not compile (error[E0005]: refutable pattern in local binding)
let Some(x) = opt;
println!("{x}");
}

A plain let requires a pattern that always matches. Some(x) might be None, so it is refutable. Use if let, let ... else, or a full match instead:

fn main() {
let opt: Option<i32> = Some(5);
let Some(x) = opt else {
println!("was None");
return;
};
println!("{x}");
}

  • Prefer explicit variant arms over _ for enums you own. A wildcard silences the exhaustiveness check, so adding a variant later will not flag the match. Use _ for open-ended primitive matches (status codes, characters) where listing everything is impossible.
  • Order arms specific-to-general. Put literals and tight ranges first, the wildcard last, to avoid unreachable-pattern warnings.
  • Use if let / let else for single-pattern checks, and reserve full match for genuine multi-way branching. let else in particular flattens “parse-or-bail” logic that would otherwise nest deeply.
  • Match by reference (match &value) when you do not need ownership, so the matched value remains usable afterward. Let match ergonomics infer the reference bindings rather than sprinkling ref.
  • Reach for matches!(value, Pattern) instead of a match that just returns true/false.
  • Use match guards for conditions that patterns cannot express (relationships between bindings, x == some_var), but keep them simple — a guard is not a place for heavy logic.
  • Let the compiler be your refactoring tool. When you add an enum variant, do not add a _ arm to silence errors; let each broken match guide you to every place that needs updating.

A small HTTP-style request router driven entirely by pattern matching. It exercises tuple matching, struct and enum destructuring, reference bindings, guards, and an exhaustive response mapping: the kind of dispatch logic you would otherwise write as a tangle of if/else in Express.

// A tiny HTTP-style request router driven entirely by pattern matching.
// Demonstrates: enum + struct destructuring, tuple matching, ref bindings,
// guards, and exhaustiveness.
#[derive(Debug)]
enum Method {
Get,
Post,
Delete,
}
#[derive(Debug)]
struct Request {
method: Method,
path: String,
body: Option<String>, // Some(body) for POST, None otherwise.
}
#[derive(Debug)]
enum Response {
Ok(String),
Created(String),
NotFound,
BadRequest(String),
}
fn route(req: &Request) -> Response {
// Match on a tuple of (&method, path-as-&str, &body). `.as_str()` lets us
// write string-literal patterns; matching by reference avoids moving `req`.
match (&req.method, req.path.as_str(), &req.body) {
(Method::Get, "/health", _) => Response::Ok("healthy".into()),
// Path parameter extracted by hand, validated with a guard.
(Method::Get, path, _) if path.starts_with("/users/") => {
match path.trim_start_matches("/users/").parse::<u32>() {
Ok(id) => Response::Ok(format!("user #{id}")),
Err(_) => Response::BadRequest("invalid user id".into()),
}
}
// Create requires a body; bind it with `Some(b)`.
(Method::Post, "/users", Some(b)) => Response::Created(format!("created: {b}")),
(Method::Post, "/users", None) => Response::BadRequest("missing body".into()),
(Method::Delete, path, _) if path.starts_with("/users/") => {
Response::Ok(format!("deleted {path}"))
}
// Catch-all: every other (method, path) combination.
_ => Response::NotFound,
}
}
fn status_line(res: &Response) -> String {
// Exhaustive: every Response variant is mapped to a status line.
match res {
Response::Ok(msg) => format!("200 OK: {msg}"),
Response::Created(msg) => format!("201 Created: {msg}"),
Response::NotFound => "404 Not Found".to_string(),
Response::BadRequest(why) => format!("400 Bad Request: {why}"),
}
}
fn main() {
let requests = [
Request { method: Method::Get, path: "/health".into(), body: None },
Request { method: Method::Get, path: "/users/42".into(), body: None },
Request { method: Method::Get, path: "/users/abc".into(), body: None },
Request { method: Method::Post, path: "/users".into(), body: Some("Ada".into()) },
Request { method: Method::Post, path: "/users".into(), body: None },
Request { method: Method::Delete, path: "/users/7".into(), body: None },
Request { method: Method::Get, path: "/unknown".into(), body: None },
];
for req in &requests {
let res = route(req);
println!(
"{:<6} {:<12} -> {}",
format!("{:?}", req.method),
req.path,
status_line(&res)
);
}
}

Output (real, from cargo run):

Get /health -> 200 OK: healthy
Get /users/42 -> 200 OK: user #42
Get /users/abc -> 400 Bad Request: invalid user id
Post /users -> 201 Created: created: Ada
Post /users -> 400 Bad Request: missing body
Delete /users/7 -> 200 OK: deleted /users/7
Get /unknown -> 404 Not Found

The router compiles cleanly under cargo clippy with no warnings. Notice how matching the (method, path, body) tuple collapses what would be a nested if/switch mess into one flat, exhaustive decision table, and the status_line match guarantees you can never add a Response variant without giving it a status line.


  • Enums — the data-carrying types you match on most often.
  • Option EnumSome/None patterns and combinators that often replace a match.
  • Structs — defining the struct shapes you destructure here.
  • Tuple Structs — matching tuple-struct and newtype patterns.
  • impl Blocks — where &self matching commonly lives.
  • Basic Types: tuples and the unit type, introduced earlier.
  • Ownership: why matching by reference vs. by value matters.
  • Collections: while let with iterators and slice patterns over Vec.

Difficulty: Easy

Objective: Write an exhaustive match over an enum.

Instructions: Given the TrafficLight enum below, implement action so that Red returns "stop", Yellow returns "slow down", and Green returns "go". Do not use a _ wildcard — handle each variant explicitly so a future variant would force you to revisit the match.

#[derive(Debug)]
enum TrafficLight {
Red,
Yellow,
Green,
}
fn action(light: &TrafficLight) -> &'static str {
// TODO: match on every variant
/* ??? */
}
fn main() {
for l in [TrafficLight::Red, TrafficLight::Yellow, TrafficLight::Green] {
println!("{l:?} -> {}", action(&l));
}
}
Solution
#[derive(Debug)]
enum TrafficLight {
Red,
Yellow,
Green,
}
fn action(light: &TrafficLight) -> &'static str {
match light {
TrafficLight::Red => "stop",
TrafficLight::Yellow => "slow down",
TrafficLight::Green => "go",
}
}
fn main() {
for l in [TrafficLight::Red, TrafficLight::Yellow, TrafficLight::Green] {
println!("{l:?} -> {}", action(&l));
}
}

Output:

Red -> stop
Yellow -> slow down
Green -> go

Difficulty: Medium

Objective: Combine struct patterns, literal patterns, and match guards.

Instructions: Implement quadrant for the Coord struct. Return "origin" for (0, 0), "on y-axis" when x == 0, "on x-axis" when y == 0, and "quadrant I"/"II"/"III"/"IV" for the four quadrants. Match by reference and use guards for the quadrant comparisons.

#[derive(Debug)]
struct Coord {
x: i32,
y: i32,
}
fn quadrant(c: &Coord) -> &'static str {
// TODO
/* ??? */
}
fn main() {
for c in [
Coord { x: 0, y: 0 },
Coord { x: 0, y: 5 },
Coord { x: 5, y: 0 },
Coord { x: 2, y: 3 },
Coord { x: -2, y: 3 },
Coord { x: -2, y: -3 },
Coord { x: 2, y: -3 },
] {
println!("{c:?} -> {}", quadrant(&c));
}
}
Solution
#[derive(Debug)]
struct Coord {
x: i32,
y: i32,
}
fn quadrant(c: &Coord) -> &'static str {
match c {
Coord { x: 0, y: 0 } => "origin",
Coord { x: 0, .. } => "on y-axis",
Coord { y: 0, .. } => "on x-axis",
// When matching `&Coord`, the bindings are `&i32`, so deref in guards.
Coord { x, y } if *x > 0 && *y > 0 => "quadrant I",
Coord { x, y } if *x < 0 && *y > 0 => "quadrant II",
Coord { x, y } if *x < 0 && *y < 0 => "quadrant III",
_ => "quadrant IV",
}
}
fn main() {
for c in [
Coord { x: 0, y: 0 },
Coord { x: 0, y: 5 },
Coord { x: 5, y: 0 },
Coord { x: 2, y: 3 },
Coord { x: -2, y: 3 },
Coord { x: -2, y: -3 },
Coord { x: 2, y: -3 },
] {
println!("{c:?} -> {}", quadrant(&c));
}
}

Output:

Coord { x: 0, y: 0 } -> origin
Coord { x: 0, y: 5 } -> on y-axis
Coord { x: 5, y: 0 } -> on x-axis
Coord { x: 2, y: 3 } -> quadrant I
Coord { x: -2, y: 3 } -> quadrant II
Coord { x: -2, y: -3 } -> quadrant III
Coord { x: 2, y: -3 } -> quadrant IV

Difficulty: Hard

Objective: Match a recursive enum with data-carrying variants, including a guard that distinguishes an empty array.

Instructions: Given the Json enum, implement summarize so that Null -> "null", Bool(b) -> "bool(true)"/"bool(false)", Number(n) -> "number(3.5)", Text(s) -> "text(N chars)" using the string’s length, an empty Array -> "empty array", and a non-empty Array -> "array of N". Match the values by reference.

#[derive(Debug)]
enum Json {
Null,
Bool(bool),
Number(f64),
Text(String),
Array(Vec<Json>),
}
fn summarize(value: &Json) -> String {
// TODO
/* ??? */
}
fn main() {
let doc = Json::Array(vec![
Json::Null,
Json::Bool(true),
Json::Number(3.5),
Json::Text("hi".into()),
Json::Array(vec![]),
]);
if let Json::Array(items) = &doc {
for item in items {
println!("{}", summarize(item));
}
}
println!("top: {}", summarize(&doc));
}
Solution
#[derive(Debug)]
enum Json {
Null,
Bool(bool),
Number(f64),
Text(String),
Array(Vec<Json>),
}
fn summarize(value: &Json) -> String {
match value {
Json::Null => "null".to_string(),
Json::Bool(b) => format!("bool({b})"),
Json::Number(n) => format!("number({n})"),
Json::Text(s) => format!("text({} chars)", s.len()),
// A guard distinguishes the empty case before the general one.
Json::Array(items) if items.is_empty() => "empty array".to_string(),
Json::Array(items) => format!("array of {}", items.len()),
}
}
fn main() {
let doc = Json::Array(vec![
Json::Null,
Json::Bool(true),
Json::Number(3.5),
Json::Text("hi".into()),
Json::Array(vec![]),
]);
if let Json::Array(items) = &doc {
for item in items {
println!("{}", summarize(item));
}
}
println!("top: {}", summarize(&doc));
}

Output:

null
bool(true)
number(3.5)
text(2 chars)
empty array
top: array of 5

Tip: The ordering of the two Json::Array arms matters: the guarded is_empty() arm must come first, because the unguarded Json::Array(items) arm would otherwise match every array and make the empty case unreachable.