Esc
Start typing to search...
Back to Blog

Pattern Matching: The Feature That Ruined Other Languages for Me

2026-01-21 Keel Team 11 min read
pattern-matchinglanguage-designtype-systemdata-science

Pattern Matching: The Feature That Ruined Other Languages for Me

There is a moment, somewhere around your third data pipeline, when you realize that most of your code is just looking at a value and asking "what shape is this?" Is the list empty? Is the result an error? Did the parse succeed? You write an if. Then another. Then a nested one. Then you go home and wonder which branch you forgot.

I have a confession: pattern matching in Keel has made me a worse Python programmer. Not because I forgot how Python works, but because every time I write if value is not None, a small voice whispers "the compiler would have caught that." I did this to myself.

What Are We Even Talking About?

A case expression inspects a value and branches based on what it finds. But pattern matching goes further -- it destructures the value in the same step, binding pieces of it to variables so you can use them in the branch body. The test and the data extraction happen simultaneously.

let x = 5
case x of
    n if n > 0 -> "positive"
    n if n < 0 -> "negative"
    _ -> "zero"
Try it

The _ is a wildcard -- it matches anything. The n if n > 0 is a guard -- it matches and binds n, but only if the condition holds. Not revolutionary so far. The revolution happens when the values get more interesting.

Destructuring: Shape and Data in One Step

The thing that made pattern matching click for me was realizing that the pattern is the destructuring. You describe the shape you expect, and the variables you name are automatically bound if the shape matches.

Tuples:

let point = (3, 4)
case point of
    (0, 0) -> "origin"
    (x, 0) -> "on x-axis"
    (0, y) -> "on y-axis"
    (x, y) -> "somewhere else"
Try it

Each arm is both a test and a binding. In Python you would destructure first, then branch separately. Small difference -- adds up fast.

Records:

let user = { name = "Carol", age = 35, email = "carol@example.com" }
case user of
    { name, .. } -> "Hello, " ++ name
Try it

That .. tells the compiler "I know there are other fields, and I am deliberately ignoring them." Without it, you must match every field:

case { name = "Bob", age = 25 } of
    { name } -> name
    -- Error: Record pattern is missing fields: age
Try it

I went back and forth on requiring ... It feels strict. But it catches a real category of bugs: someone adds a field to the record six months from now, and your old code still compiles because you explicitly opted out. The type system enforces this at compile time, not as a lint you can disable.

Nested tuples:

let pair = ((1, 2), (3, 4))
case pair of
    ((a, b), (c, d)) -> a + b + c + d   -- 10
    _ -> 0
Try it

Lists: Head, Tail, and Satisfying Recursion

The cons pattern x :: xs splits a list into its first element and the rest:

let list = [1, 2, 3, 4]
case list of
    a :: b :: rest -> a + b   -- 3 (first two elements)
    x :: xs -> x
    [] -> 0
Try it

Where this really shines is in recursive functions. A sum function reads almost like its mathematical definition:

module TestMath exposing (sum)
    fn sum : List Int -> Int
    fn sum list =
        case list of
            x :: xs -> x + sum xs
            [] -> 0

TestMath.sum [1, 2, 3, 4, 5]    -- 15
Try it

Both this and the equivalent len function come directly from the test suite -- they are not invented examples. Getting cons patterns to parse correctly was one of the trickier bits, because :: also appears in qualified enum access like Color::Red. The parser disambiguates by context: lowercase on the left means cons, uppercase means qualification. Took me longer than I care to admit.

Maybe and Result: The Load-Bearing Patterns

Pattern matching stops being a convenience and starts being structurally important when combined with Maybe and Result. As the Maybe and Result post covers in detail, Keel has no null. If a value might be absent, its type is Maybe, and you must handle both cases:

let m = Just 42
case m of
    Just x  -> x       -- 42
    Nothing -> 0       -- you must handle this
Try it

Fallible operations use Result:

let x = Err "oops"
case x of
    Ok v -> "got: " ++ v
    Err e -> "error: " ++ e
-- "error: oops"
Try it

Try using an Ok pattern on a Maybe value and the compiler catches the type mismatch before the code ever runs.

Guards combine naturally with Maybe matching:

let x = Just 10
case x of
    Just n if n > 5 -> "big just"
    Just n -> "small just"
    Nothing -> "nothing"
-- "big just"
Try it

When the guard does not match, execution falls through. Just 3 would skip the n > 5 guard and land on Just n -> "small just" instead.

Or-Patterns: Grouping Without Repetition

Sometimes several patterns should lead to the same result. Instead of duplicating the branch body, use | to combine alternatives:

case "hi" of
    "hello" | "hi" | "hey" -> "greeting"
    _ -> "other"
-- "greeting"
Try it

Or-patterns can bind shared variables across alternatives:

case (7, 3) of
    (x, 1) | (x, 2) | (x, 3) -> x * 2   -- 14
    _ -> 0
Try it

The variable x must be bound in every alternative. If you tried to use a variable that only appears in some branches, the compiler would catch it. Try getting that guarantee from a chain of elif in Python.

Or-patterns across Result variants turn out to be surprisingly useful:

let r: Result Int Int = Ok 5
case r of
    Ok x | Err x -> x    -- 5: extract the Int either way
    _ -> 0
Try it

I was not sure or-patterns with shared bindings were worth the implementation complexity. Then I used one in real code and deleted about fifteen lines. They were worth it.

Guards: When Shape Is Not Enough

Guards add boolean expressions to pattern arms. I specifically wanted these for data work -- classifying numeric ranges, filtering by computed properties:

let n = 15
case n of
    x if x > 0 && x < 10 -> "single digit"
    x if x >= 10 && x < 100 -> "double digit"
    _ -> "other"
-- "double digit"
Try it

Guards pair well with destructuring:

let point = (5, 10)
case point of
    (x, y) if x == y -> "diagonal"
    (x, y) if x > y -> "right"
    (x, y) -> "left or up"
-- "left or up"
Try it

The guard must return Bool. Write one that returns Int and the compiler tells you. Small in implementation, large in payoff.

The Safety Net: Exhaustiveness Checking

Here is the feature that takes pattern matching from "nice syntax" to "I refuse to work without this." The compiler checks that your patterns cover every possible value:

type Color = Red | Green | Blue

case color of
    Red -> "red"
    Green -> "green"
    -- Warning: Non-exhaustive pattern match: missing variants: Blue
Try it

Add a new variant to an enum? The compiler shows you every case expression that needs updating. It also catches unreachable patterns:

case x of
    _ -> "default"
    5 -> "five"     -- Warning: Unreachable pattern
Try it

And when guards make exhaustiveness unverifiable, the compiler is honest:

case x of
    n if n > 0 -> "positive"
    n if n < 0 -> "negative"
    -- Warning: Guard exhaustiveness cannot be verified
    -- Add a catch-all pattern `_ ->` to ensure all cases are handled
Try it

It does not pretend it can solve arbitrary boolean satisfiability. It says "I cannot prove this is complete, here is what to do about it." More trustworthy than silence. The type checker post covers the algorithm in detail.

Nested Patterns: Deep Data, One Expression

Real data is layered. Keel handles this with nested pattern matching:

let result: Result String (Maybe Int) = Ok (Just 5)
case result of
    Ok (Just x) -> x * 2     -- 10
    Ok Nothing  -> 0
    Err msg     -> -1
Try it

One expression, three distinct outcomes. The Python equivalent would be nested if isinstance checks. The R equivalent would be nested if/else with is.null(). Both would be easy to get wrong and hard to verify.

Type Validation: Catching Nonsense Early

Beyond exhaustiveness, Keel validates that patterns make sense for the value being matched:

case 42 of
    "hello" -> 1   -- Error: Pattern type mismatch: expected Int, but pattern is String
    _ -> 0
Try it

You cannot mix Maybe and Result patterns. All branches must return the same type. These checks happen at compile time -- the VM never sees code that would try to destructure an Int as a tuple.

Qualified Enum Patterns

When variant names might be ambiguous, qualified patterns add clarity:

type Color = Red | Green | Blue
let color = Color::Green
case color of
    Color::Red   -> "fire"
    Color::Green -> "nature"
    Color::Blue  -> "sky"
Try it

Both qualified (Color::Green) and bare (Green) work.

What You Get (That Python and R Do Not)

FeaturePythonRKeel
Exhaustiveness checkingNoNoYes (compile-time)
Destructuring in conditionsLimited (3.10+)NoYes
Null safety via patternsNoNoYes (Maybe required)
Or-patterns with shared bindingsPartial (3.10+)NoYes
Guard expressionsYes (3.10+)NoYes
Cons patterns for listsNoNoYes (x :: xs)
Record field completenessNoNoYes (.. required)
Unreachable pattern detectionNoNoYes
Pattern type validationNoNoYes

Python 3.10 added structural pattern matching, which gets closer. But it still runs at runtime and does not check exhaustiveness. R has no pattern matching at all.

I do not say this to dunk on Python or R. They are excellent at what they do. But for "react to the shape of data and guarantee you handled every case," they leave you without a net.

Under the Hood

The parser turns patterns into a Pattern enum with 13 variants. The compiler generates bytecode that tests each arm in order -- constructor patterns emit type-tag comparisons, literal patterns emit equality checks, destructuring patterns emit field/index loads into registers. The exhaustiveness checker runs after parsing but before bytecode generation, building a set of covered variants and checking for gaps.

Pattern matching compiles to sequential tests with jumps in the register-based VM, similar to how a switch compiles in C. Getting it right was one of those puzzles where every piece you place reveals three more pieces you had not considered.

Closing Thoughts

The one thing I keep coming back to is how pattern matching changes the way you think about data. In Python or R, you look at a value and ask "what should I do with this?" and write branching logic to figure it out. In Keel, you describe the shapes you expect and what to do for each one, and the compiler makes sure you did not miss any. It takes some getting used to. But once you do, going back feels like driving without mirrors.

If you want to try it, head to the playground or the getting started guide. Write a case expression, forget a variant, and watch the compiler catch it. Then try the same thing in Python and notice the silence.