Back to flin
flin

Pattern Matching: From Switch to Match

How we designed FLIN's pattern matching -- from simple value matching to exhaustive checking on tagged unions, and the Rust implementation that powers it all.

Thales & Claude | March 25, 2026 10 min flin
flinpattern-matchingmatchexhaustiveness

Every programming language has a way to branch on a value. JavaScript has switch. Python has match (since 3.10). Rust has match. But there is a profound difference between a switch statement and true pattern matching, and that difference is the reason Session 145 through Session 157 were some of the most important in FLIN's development.

A switch compares a value against a list of constants. Pattern matching decomposes a value into its constituent parts, binds those parts to names, and ensures that every possible shape of the value is handled. It is the difference between "which number is this?" and "what is this thing made of, and have I accounted for everything it could be?"

The Starting Point: Match on Values

FLIN's match expression started simple. Before Sessions 145-157, the language already had value-based matching from the language spec:

result = match status {
    "active" -> "User is active"
    "pending" -> "Waiting for approval"
    "suspended" -> "Account suspended"
    _ -> "Unknown status"
}

This is essentially a switch statement with a cleaner syntax. The _ is a wildcard that catches anything not matched by a previous arm. The -> separates the pattern from the body. The entire match is an expression -- it evaluates to a value.

But value matching has severe limitations. You cannot match on the structure of a value. You cannot bind parts of a value to names. And you cannot ask the compiler to verify that you handled every case. Sessions 145-157 addressed all three limitations.

Pattern Matching on Types

The first extension was matching on types. With union types in the language, developers needed a way to handle each member of a union:

value: int | text | bool = getData()

match value { int -> print("Got integer: " + text(value)) text -> print("Got string: " + value) bool -> print("Got boolean: " + text(value)) } ```

Each arm matches a type, and inside the arm body, the value is narrowed to that type. This is the same type narrowing mechanism from the is operator, but applied systematically across all branches.

In the compiler, type-based match arms are represented as a new pattern variant:

pub enum Pattern {
    Literal(Literal),           // "active", 42, true
    Identifier(String),          // x (binds value to name)
    Wildcard,                    // _
    TypeCheck(Type),             // int, text, bool
    EnumVariant {                // Ok(value), None
        enum_name: String,
        variant: String,
        inner: Option<Box<Pattern>>,
    },
    Or(Vec<Pattern>),            // pattern1 | pattern2
    // ...
}

The TypeCheck variant carries the type to match against. The type checker validates that every type in the match is a member of the union being matched on.

Pattern Matching on Enums

With tagged unions (Session 145), pattern matching became essential. An enum variant can carry associated data, and the only way to access that data is through pattern matching:

enum Result<T, E> {
    Ok(T),
    Err(E)
}

result: Result = Ok(42)

match result { Ok(value) -> print("Success: " + text(value)) Err(msg) -> print("Error: " + msg) } ```

The Ok(value) pattern does two things simultaneously: it checks that the result is the Ok variant, and it binds the associated data to the name value. Inside the arm body, value has the type int (because Result's Ok variant carries a T = int).

The AST representation uses Pattern::EnumVariant:

Pattern::EnumVariant {
    enum_name: "Result".to_string(),
    variant: "Ok".to_string(),
    inner: Some(Box::new(Pattern::Identifier("value".to_string()))),
}

The inner pattern is recursive. You can nest patterns inside enum variants:

enum Nested {
    Pair(int, int),
    Single(int),
    Empty
}

match nested { Pair(x, y) -> x + y Single(x) -> x Empty -> 0 } ```

Exhaustiveness Checking

The most important feature of pattern matching is something the developer never sees: exhaustiveness checking. The compiler verifies that every possible value is handled by at least one arm.

enum Color {
    Red,
    Green,
    Blue
}

match color { Red -> "#FF0000" Green -> "#00FF00" // ERROR: non-exhaustive match -- missing variant: Blue } ```

Without the Blue arm, the compiler rejects the match. This is not a warning. It is an error. Every match on an enum must cover every variant.

The exhaustiveness checker works by computing the set of unmatched patterns:

fn check_exhaustiveness(
    &self,
    matched_type: &FlinType,
    arms: &[MatchArm],
    span: Span,
) {
    match matched_type {
        FlinType::Enum { variants, .. } => {
            let mut uncovered: Vec<String> = variants
                .iter()
                .map(|(name, _)| name.clone())
                .collect();

for arm in arms { match &arm.pattern { Pattern::EnumVariant { variant, .. } => { uncovered.retain(|v| v != variant); } Pattern::Wildcard => { uncovered.clear(); } _ => {} } }

if !uncovered.is_empty() { self.report_error(&format!( "non-exhaustive match: missing variant(s): {}", uncovered.join(", ") )); } } FlinType::Bool => { let has_true = arms.iter().any(|a| matches!(&a.pattern, Pattern::Literal(Literal::Bool(true)))); let has_false = arms.iter().any(|a| matches!(&a.pattern, Pattern::Literal(Literal::Bool(false)))); let has_wildcard = arms.iter().any(|a| matches!(&a.pattern, Pattern::Wildcard));

if !has_wildcard && (!has_true || !has_false) { self.report_error("non-exhaustive match on bool"); } } _ => { // For non-enum types, require a wildcard arm let has_wildcard = arms.iter().any(|a| matches!(&a.pattern, Pattern::Wildcard)); if !has_wildcard { self.report_error("match requires a wildcard '_' arm for this type"); } } } } ```

The algorithm is straightforward for enums: start with all variants, remove each one that has a matching arm, and report any that remain. For booleans, check that both true and false are covered. For all other types (integers, strings), require a wildcard arm because the set of possible values is infinite.

Or-Patterns

Sometimes multiple patterns should execute the same code. Or-patterns combine multiple patterns with |:

match status {
    "active" | "enabled" -> showActive()
    "pending" | "waiting" -> showPending()
    _ -> showUnknown()
}

Or-patterns also work with enum variants:

enum Shape {
    Circle(number),
    Square(number),
    Rectangle(number, number),
    Point
}

match shape { Circle(r) | Square(r) -> print("Size: " + text(r)) Rectangle(w, h) -> print("Area: " + text(w * h)) Point -> print("Point") } ```

The or-pattern Circle(r) | Square(r) matches either variant and binds the inner value to r. The type checker verifies that all alternatives in an or-pattern bind the same names with compatible types.

The implementation adds the Pattern::Or variant:

Pattern::Or(Vec<Pattern>)

During exhaustiveness checking, an or-pattern counts as covering multiple variants. During code generation, it generates a check for each alternative with a shared body.

Match as an Expression

In FLIN, match is an expression. It evaluates to a value:

color_code = match color {
    Red -> "#FF0000"
    Green -> "#00FF00"
    Blue -> "#0000FF"
}

This means the type checker must verify that every arm produces a value of the same type (or compatible types):

fn check_match_expression(&mut self, arms: &[MatchArm]) -> FlinType {
    let mut result_type: Option<FlinType> = None;

for arm in arms { let arm_type = self.infer_type(&arm.body);

match &result_type { None => result_type = Some(arm_type), Some(existing) => { if !self.types_compatible(existing, &arm_type) { // Try to find a common type let unified = self.unify(existing, &arm_type); result_type = Some(unified); } } } }

result_type.unwrap_or(FlinType::Unknown) } ```

If one arm returns int and another returns text, the result type is int | text. The match expression's type is the union of all arm types. This interacts naturally with the union type system from Session 100.

Guard Clauses

Pattern matching also supports guard clauses -- additional boolean conditions on a match arm:

match value {
    x if x > 0 -> "positive"
    x if x < 0 -> "negative"
    _ -> "zero"
}

Guard clauses provide filtering that patterns alone cannot express. A pattern matches on structure; a guard matches on value constraints. Together, they cover every branching need.

The AST representation adds an optional guard to match arms:

pub struct MatchArm {
    pub pattern: Pattern,
    pub guard: Option<Expr>,
    pub body: Expr,
    pub span: Span,
}

The type checker evaluates the guard as a boolean expression and does not count guarded arms as exhaustive (because the guard might be false even when the pattern matches).

Block Bodies

When a match arm needs multiple statements, it uses a block:

match result {
    Ok(value) -> {
        processed = transform(value)
        save processed
        processed.id
    }
    Err(msg) -> {
        log("Error: " + msg)
        -1
    }
}

The last expression in the block is the arm's value. This is consistent with FLIN's general rule that blocks evaluate to their last expression.

Code Generation

Match expressions compile to a series of conditional checks in the bytecode:

fn emit_match(&mut self, subject: &Expr, arms: &[MatchArm]) {
    // Evaluate the subject once
    self.emit_expr(subject);
    let subject_local = self.allocate_temp();
    self.emit_store(subject_local);

let mut end_jumps = vec![];

for arm in arms { // Load subject and check pattern self.emit_load(subject_local); let skip_jump = self.emit_pattern_check(&arm.pattern);

// If guard exists, check it if let Some(guard) = &arm.guard { let guard_skip = self.emit_guard_check(guard); // Bind pattern variables and emit body self.emit_pattern_bindings(&arm.pattern, subject_local); self.emit_expr(&arm.body); end_jumps.push(self.emit_jump_forward()); self.patch_jump(guard_skip); } else { // Bind pattern variables and emit body self.emit_pattern_bindings(&arm.pattern, subject_local); self.emit_expr(&arm.body); end_jumps.push(self.emit_jump_forward()); }

self.patch_jump(skip_jump); }

// Patch all end jumps to after the match for jump in end_jumps { self.patch_jump(jump); } } ```

The generated bytecode evaluates the subject once, then checks each pattern in order. When a pattern matches (and the guard passes, if present), the pattern's bindings are established and the arm body executes. A forward jump skips to the end of the match.

The Evolution Across Sessions

Pattern matching was not built in one session. It evolved across Sessions 145-157:

  • Session 145: Tagged unions and basic enum pattern matching
  • Session 147: Exhaustiveness checking for enums and booleans
  • Session 149: Guard clauses on match arms
  • Session 152-153: While-let patterns (loop-based pattern matching)
  • Session 154-155: Or-patterns and labeled loops
  • Session 157: Final refinements and edge case handling

Each session added a layer of capability. By Session 157, FLIN's pattern matching was feature-complete: value patterns, type patterns, enum variant patterns with destructuring, or-patterns, guard clauses, exhaustiveness checking, and match-as-expression.

Why Pattern Matching Matters

Pattern matching is not just a nicer syntax for switch statements. It is a fundamental tool for writing correct code.

When you match on a tagged union with exhaustiveness checking, the compiler guarantees that every possible case is handled. When you add a new variant to an enum, every match expression in the codebase that touches that enum will fail to compile until you add the new case. This turns "did I update every switch statement?" from a manual review task into an automated compiler check.

For FLIN's target audience -- developers building full-stack applications -- pattern matching provides safety without ceremony. You do not need to think about which cases you might have forgotten. The compiler remembers for you.

The next article explores the feature that makes pattern matching truly powerful: tagged unions and algebraic data types.

---

This is Part 35 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and implemented a programming language from scratch.

Series Navigation: - [33] Generic Types in FLIN - [34] Traits and Interfaces - [35] Pattern Matching: From Switch to Match (you are here) - [36] Tagged Unions and Algebraic Data Types - [37] Destructuring Everywhere

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles