← All posts

Cure v0.18.0 :: Deep Destructuring—pattern matching grows up

by Aleksei Matiushkin

release pattern-matching destructuring records maps cons pin

For seventeen releases Cure’s pattern matching was a polite demonstration. You could write match xs { [h | t] -> ... }. You could write match opt { Some(v) -> v; None() -> 0 }. But the moment the shape got interesting—a tuple whose middle slot was a map whose :list field was a cons, or a record pattern whose field was itself a record pattern—the compiler quietly lost interest and handed back whatever it could. Map patterns in particular were miscompiled: they emitted the Erlang construction form (K => V) instead of the match form (K := V), so a map pattern accepted any subject. We had tests pointing squarely at the broken behaviour; what we did not have was a pattern compiler willing to recurse.

v0.18.0 replaces the pattern layer wholesale.

The headline

This now works, end to end:

match value
  %[_, %{list: [head | tail]}, _]          -> handle(head, tail)
  Person{name, address: Address{city}}     -> greet(name, city)
  [Ok(v) | _]                              -> v
  _                                        -> default

Every sub-pattern in that snippet is compiled as a real pattern. The nested map patterns use exact matching, so they actually require the key. The record patterns check the struct tag and each field. The cons pattern binds v through the Ok(...) constructor. The wildcard covers the rest.

A dedicated pattern compiler

The first architectural move is to separate pattern compilation from expression compilation. Pattern AST nodes look like expression AST nodes (both are MetaAST 3-tuples), but they have fundamentally different semantics: an expression is evaluated, a pattern is matched against a subject and binds variables as a side effect. Treating them uniformly was the original sin.

The new Cure.Compiler.PatternCompiler has a single compile/2 entry point and dispatches on the pattern shape:

  • {:tuple, _, elems}, {:list, _, elems}, {:list, [cons: true], [h, t]} recurse through do_compile/2 for every child. No fall-through to expression codegen.
  • {:map, _, pairs} emits map_field_exact forms ({:map_field_exact, L, Kform, Vform}), not the construction-form map_field_assoc. This is the one-line fix that makes map patterns actually work.
  • {:function_call, [record: true, name: T], fields} lowers to a map pattern with __struct__ := :t plus one exact entry per field. Unspecified fields are open-matched, so Person{name} matches any person regardless of their other fields.
  • {:function_call, [name: Tag], args} with a PascalCase tag lowers to a tagged tuple, recursing into each argument as a pattern.
  • Bare identifiers inside a record pattern are field-punning shorthand: Point{x, y} desugars to Point{x: x, y: y}.
  • {:pin, _, [{:variable, _, name}]} lowers to a fresh variable plus a guard V_fresh =:= V_name. The pin operator is the escape hatch for "compare against this already-bound value".
  • Repeated occurrences of the same variable in one pattern follow the same rule: the first occurrence binds, later occurrences become equality guards. %[x, x] now matches exactly the pairs where both slots are equal.

Everywhere the codegen used to call compile_pattern/2 -- compile_multi_clause_function, compile_pattern_match, compile_assignment, compile_comprehension, compile_catch_and_finally—now delegates to the new module and folds the synthetic pattern guards into the clause guard using andalso.

The type checker catches up

Cure.Types.Checker.bind_pattern_vars/3 was the other half of the old story: even when the codegen did the right thing, the type checker gave every nested pattern variable the type :any, so match p { Person{age: a} when a > 17 -> ... } never actually knew a : Int.

The rewrite threads the scrutinee type through every pattern shape:

  • Tuple patterns zip element-wise against the tuple element types.
  • List and cons patterns bind the head to the list element type and the tail to the list type itself.
  • Map patterns look up each key in the scrutinee (if the scrutinee is a known record, through its schema; otherwise through the map value type).
  • Record patterns resolve every field against the schema registered at rec time. Unknown fields emit a warning under code E021.

The practical effect is that every variable bound by a nested pattern now carries the tightest type the structure allows, and subsequent refinement checks fire against that type instead of against :any.

Nested exhaustiveness

v0.11.0 added a flat exhaustiveness pass: classify each arm as :wildcard | :empty_list | :cons | {:literal, ...} | {:constructor, ...} | {:tuple, n}, union them against the scrutinee type, warn if something is missing. That pass stays, because it is fast and covers the common case.

v0.18.0 adds a second pass: a best-effort Maranget-style column walker (Cure.Types.PatternChecker.check_nested/2) that descends into tuple scrutinees whose element types are enumerable. It collects the set of inhabited constructors per column, computes the missing ones, and emits concrete source-shaped witnesses:

Warning: match expression has nested non-exhaustive cases (E025)
  missing: %[Error(_), _]

The witness is the pattern you would have to write to cover the gap.

Five new error codes

E021E025 in Cure.Compiler.Errors:

  • E021 Unknown record field in pattern.
  • E022 Record pattern field type mismatch.
  • E023 Non-literal map pattern key.
  • E024 Unbound pin variable.
  • E025 Non-exhaustive nested match.

All surface through cure explain E0xx. The catalog is now at 25 codes.

Field punning in both directions

Point{x, y} is shorthand for Point{x: x, y: y} in pattern position and in construction position—the parser change is agnostic. The saving shows up most in constructors that immediately forward their parameters:

fn make_point(x: Int, y: Int) -> Point = Point{x, y}

For record patterns the saving is the same shape, but the win is larger because patterns tend to have many fields and the punning eliminates the repetition.

The pin operator

Cure’s lambda-style pattern matching always treated a variable as a fresh binding. That matches Erlang’s default and is usually what you want, but it makes "compare against this value" awkward:

# Pre-v0.18: you had to do this
let target = get_tag()
match event.tag
  t when t == target -> :hit
  _                  -> :miss

With the pin operator:

# v0.18.0
let target = get_tag()
match event.tag
  ^target -> :hit
  _       -> :miss

No runtime cost: the compiler lowers ^target to a fresh variable plus the same equality guard you would have written by hand.

A stdlib module that exists to dogfood the engine

Std.Match is a brand-new, tiny stdlib module whose whole purpose is to exercise the pattern engine:

fn first_two(list: List(T), default: T) -> Tuple =
  match list
    [h1 | t1] ->
      match t1
        [h2 | rest] -> %[h1, h2, rest]
        []          -> %[h1, default, []]
    []        -> %[default, default, []]

Eight such helpers (unpack_pair/1, fst/1, snd/1, head_tail/2, uncons/1, first_two/2, unwrap_ok/2, unwrap_some/2) are now in lib/std/match.cure, each implemented with nested patterns. They double as integration tests: if the pattern engine regresses, this module stops compiling.

Std.List.uncons/1 and Std.List.split_first/2 were also added, for the same reason.

Numbers

  • 1 new Elixir module (Cure.Compiler.PatternCompiler, ~480 LOC).
  • Rewrites inside Codegen, Checker, and PatternChecker.
  • 1 new stdlib module (Std.Match); 21 total.
  • 2 new examples (examples/destructuring.cure, examples/json_tree.cure) + extension of pattern_guards.cure.
  • 2 new docs (docs/PATTERNS.md, a new Chapter 4 in docs/TUTORIAL.md).
  • New error codes E021--E025.
  • 923 tests (3 doctests, 920 tests). 26 new, 0 regressions.
  • Zero credo issues in strict mode. mix cure.check.stdlib: 21/21 clean. mix cure.check.examples: 26/26 clean.

Upgrade notes

v0.18.0 is source-compatible with v0.17.0 for well-formed programs. Two caveats:

  1. Map patterns that used to accidentally succeed now have to be correct. If your code relied on the old (broken) map-pattern behaviour—for instance, a map pattern whose key was not present in the subject but which the compiler accepted anyway—you will now get a runtime badmatch or a compile-time non-exhaustive warning. Fix the pattern.

  2. None vs None(). Bare None was always a variable binding; None() is the nullary constructor. The new type checker now emits :unknown_record_field warnings for the cases where you meant None() but wrote None inside a record pattern. No automatic migration is provided; the fix is mechanical.

What’s next—v0.19.0 Bring the Furniture

v0.18.0 deliberately did one thing: deep destructuring. The previously-slated v0.18.0 items land in v0.19.0:

  • proof containers for laws-as-programs.
  • assert_type builtin.
  • Records with default field values.
  • @derive(Show, Eq, Ord) fully wired through codegen.
  • Property-based testing in Std.Test via Std.Gen + forall/2.
  • Std.Iter lazy iterator protocol.
  • First half of the package registry—version-constraint parser, resolver, deeper Cure.lock semantics.
  • Mutual-recursion totality.
  • Pin operator promoted to default (it lives behind --experimental-pin in v0.18.0).
  • Multi-head cons patterns [a, b | rest] and full bitstring pattern specifiers.

Getting started

git clone https://github.com/am-kantox/cure-lang.git
cd cure
mix deps.get && mix test
mix escript.build
./cure version            # Cure 0.18.0
./cure run examples/destructuring.cure
./cure run examples/json_tree.cure

The repository is at github.com/am-kantox/cure-lang. Pattern matching, at last, actually matches.