Hedronite Lesson · Polyglot-Dev / Rust · Fri 2026-09-04

Enums Option match if let — the case that is not a sentinel

A boolean plus a magic number guesses at absence. An enum names every case, and match refuses to leave one out.

Lesson Class: Duha (Rust language track)
Focus: defining enums · variants with data · Option · match arms · exhaustiveness · catch-all · if let · let else
Code Blocks: clean blocks, explanation in prose
Done-criteria: replace a boolean-plus-sentinel with an enum and exhaust match
Grounding: TRPL stable Ch.6 HTML ch06-00 through ch06-03 · Blandy not cited
The variant
An enum value is exactly one named case. Each case may carry its own data.
The absence
Option marks presence or absence in the type. T and Option of T never mix by accident.
The exhaust
match must cover every case. if let trades that check for brevity when one arm matters.
A boolean plus a magic number guesses at absence. An enum names every case, and match refuses to leave one out.

<!-- hal:authoritative:yaml -->

A boolean plus a magic number guesses at absence. An enum names every case, and match refuses to leave one out.

§I — Frame

Duha session 07. TRPL Chapter 6 is one syllabus row, so this fire takes the whole chapter at chapter pace. Session 06 established structs and methods. Keep named fields and impl in hand when a variant holds data shaped like a struct, and when Message gains a method. Do not reopen Chapter 5.

A struct gathers related fields into one value. An enum says a value is exactly one case from a named set. IpAddrKind::V4 and IpAddrKind::V6 are different variants of one type. The program cannot be both at once.

Languages without this tool often fake the set with a boolean and a sentinel: found plus -1, or ok plus a null-shaped field. Those markers live outside the type. Rust puts the cases inside the type.

Coin the name: the case that is not a sentinel.

By the end, replace a boolean-plus-sentinel encoding with an enum, then exhaust every variant with match. That is the syllabus test.

Structs answered "what fields travel together." Enums answer "which named situation is this value in." Both create custom types the compiler checks. Chapter 6 adds the control-flow tools that open those situations safely.

§II — Define the type, name the variants

An enum definition starts with enum, names the type, and lists variants:

enum IpAddrKind {
    V4,
    V6,
}

Each variant is namespaced under the type: IpAddrKind::V4 and IpAddrKind::V6. Both values share the type IpAddrKind, so one function can accept either:

fn route(ip_kind: IpAddrKind) {}

fn main() {
    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

The double colon is the same path punctuation used for associated functions on structs. Here it selects a variant constructor with no data.

Kind alone is rarely enough. A first instinct after Chapter 5 is to wrap the enum in a struct:

struct IpAddr {
    kind: IpAddrKind,
    address: String,
}

let home = IpAddr {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
};

That works. The enum already can carry the address itself, which removes the extra type:

enum IpAddr {
    V4(String),
    V6(String),
}

let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));

Each variant name becomes a constructor function. IpAddr::V4(...) returns an IpAddr. Variants may also hold different shapes. Version four can store four u8 octets while version six keeps a String:

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

A struct with one shared address field cannot do that without awkward options. The enum lets each case declare its own payload.

The standard library uses the same idea with embedded structs: IpAddr::V4(Ipv4Addr) and IpAddr::V6(Ipv6Addr). Custom definitions remain valid when the prelude type stays out of scope. Chapter 7 covers bringing library types into scope.

§III — One type, many payload shapes

Listing 6-2 in TRPL shows how far a single enum can stretch:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Four variants, four shapes:

  1. Quit holds nothing. It is unit-like.
  2. Move holds named fields, like a struct literal inside the variant.
  3. Write holds one String, like a tuple struct with one field.
  4. ChangeColor holds three i32 values.

Four separate structs could store the same data, but then no single function parameter type would accept every message. Message is one type. Methods attach with impl, the same way they attach to structs:

impl Message {
    fn call(&self) {
        // handle self by variant later with match
    }
}

let m = Message::Write(String::from("hello"));
m.call();

&self still means a shared borrow of the whole enum value. Ownership rules from Chapter 4 still apply to the payload fields when you move them out.

§IV — Option marks presence in the type

The standard library defines Option<T> roughly as:

enum Option<T> {
    None,
    Some(T),
}

T is a generic placeholder covered fully in Chapter 10. For today: Some holds one value of some concrete type, and each choice of T makes a distinct Option type. Option<i32> is not Option<char>.

Option and its variants live in the prelude. Write Some(5) and None without an Option:: prefix:

let some_number = Some(5);
let some_char = Some('e');
let absent_number: Option<i32> = None;

None alone gives the compiler no inner type to infer, so annotate Option<i32> (or whatever T you mean).

Rust has no null. The idea of absence still matters, so the language encodes it as an ordinary enum. T and Option<T> are different types. You cannot add an i8 to an Option<i8>:

let x: i8 = 5;
let y: Option<i8> = Some(5);
// let sum = x + y; // does not compile

Where the type is plain i8, the value is present. Where the type is Option<i8>, the program must handle both Some and None before using an inner value. That is the case that is not a sentinel: absence is a named variant, not a magic bit beside a real number.

Option also has many methods documented in the standard library. Learning them later will save typing. For this chapter, prefer explicit match (or if let) so the handling stays visible in the source.

§V — match sorts every case

match compares a value against patterns and runs the first arm that fits. TRPL likens it to a coin sorter: the value falls through until a pattern accepts it.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

Unlike if, the scrutinee need not be a boolean. Each arm is pattern => expression. Short arms omit braces. Multi-line arms use a block; the block's last expression is the arm's value:

Coin::Penny => {
    println!("Lucky penny!");
    1
}

Patterns can bind data inside a matching variant. Give Quarter a UsState payload:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {state:?}!");
            25
        }
    }
}

When the value is Coin::Quarter(UsState::Alaska), the arm Coin::Quarter(state) binds state to UsState::Alaska. The payload leaves the enum through the pattern.

The same shape opens Option:

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}

Some(i) binds the inner i32. None returns None without inventing a number. The return type stays Option<i32>, so presence and absence remain visible to callers.

§VI — Exhaust, then catch the rest

match is exhaustive. Every possible value of the scrutinee type must match some arm. Dropping None from plus_one fails to compile with non-exhaustive patterns. The compiler names the missing case. That check is the safety that sentinels never give you.

When many values share one action, a catch-all arm covers the remainder. A named binding keeps the value:

let dice_roll = 9;
match dice_roll {
    3 => add_fancy_hat(),
    7 => remove_fancy_hat(),
    other => move_player(other),
}

other must come last. Patterns run in order. An earlier catch-all would hide later arms, and Rust warns if arms follow a catch-all.

When the leftover value is unused, write _:

match dice_roll {
    3 => add_fancy_hat(),
    7 => remove_fancy_hat(),
    _ => reroll(),
}

_ matches anything and binds nothing. To do nothing for the rest, return the unit value:

_ => (),

Exhaustiveness still holds. You named the leftover case on purpose.

§VII — if let and let...else

Sometimes only one pattern matters. A full match still needs a _ => () arm for the rest. if let keeps the interesting arm and drops the boilerplate:

let config_max = Some(3u8);
if let Some(max) = config_max {
    println!("The maximum is configured to be {max}");
}

The form is if let pattern = expression. It behaves like a one-arm match. You gain brevity and lose exhaustiveness checking. Choose match when every case must stay visible. Choose if let when ignoring the rest is intentional.

Pair else with if let when the non-matching path needs work:

let mut count = 0;
if let Coin::Quarter(state) = coin {
    println!("State quarter from {state:?}!");
} else {
    count += 1;
}

TRPL also shows let...else for the happy path. The pattern binds in the outer scope on success. On failure, the else block must diverge, often with return:

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

The main body stays linear. The early exit for non-quarters sits beside the binding, not nested under an if let.

§VIII — One complete proof

Replace a boolean-plus-sentinel sketch with an enum, then exhaust it.

#[derive(Debug)]
enum ParseOutcome {
    Ok(i32),
    Empty,
    BadToken(String),
}

fn classify(raw: &str) -> ParseOutcome {
    if raw.is_empty() {
        ParseOutcome::Empty
    } else if let Ok(n) = raw.parse::<i32>() {
        ParseOutcome::Ok(n)
    } else {
        ParseOutcome::BadToken(raw.to_string())
    }
}

fn score(outcome: ParseOutcome) -> i32 {
    match outcome {
        ParseOutcome::Ok(n) => n,
        ParseOutcome::Empty => 0,
        ParseOutcome::BadToken(token) => {
            println!("reject {token}");
            -1
        }
    }
}

fn main() {
    let first = classify("42");
    let second = classify("");
    let third = classify("nope");

    println!("scores: {}, {}, {}", score(first), score(second), score(third));

    if let ParseOutcome::Ok(n) = classify("7") {
        println!("only the ok path: {n}");
    }
}

A weaker encoding might have used success: bool plus value: i32 plus maybe an error string, with -1 or 0 as a fake empty result. ParseOutcome names three cases. score must handle all three or the program will not compile. The if let at the end shows the concise path when only Ok matters.

That meets the syllabus criterion: an enum in place of a boolean-plus-sentinel, and an exhaustive match.

Notice what the old encoding hid. A boolean named ok does not say why a parse failed. A sentinel integer collides with a real answer. ParseOutcome makes Empty and BadToken distinct from Ok, so later code cannot treat an error token as a number without first changing the type.

§IX — Closing

Enums enumerate the cases a value may take. Variants may hold nothing, a tuple of values, or named fields. Different variants of one enum may hold different payloads. Methods attach through impl.

Option<T> is the library enum for presence and absence. Some(T) and None are ordinary variants. T never pretends to be optional unless its type says so.

match runs the first matching arm and demands every possibility be covered. Patterns bind inner data. Catch-all arms use a name or _. if let and let...else shorten the one-pattern case when losing exhaustiveness is an acceptable trade.

Name the hinge when ok and -1 become ParseOutcome::Ok(n) and ParseOutcome::Empty: the case that is not a sentinel. The possibilities were already in the problem. The enum puts them in the type, and match keeps every one honest.

Session 08 is TRPL Chapter 7: packages, crates, and modules. Stop here.

Examine well. Define one enum with at least three differently shaped variants. Write an exhaustive match that binds data from one variant. Then rewrite a one-interesting-arm match as if let, and write one let...else that returns early on the non-matching path.

Related

🛡️ ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-09-04 · Duha · session 07 · TRPL Ch.6 · the case that is not a sentinel