ADR-291: A let Takes Names or a Flat Tuple, _ Ignores a Value That Arrived, and a match Takes Nested Patterns, Ends in else, Covers Every Case and Lends What an Arm Only Reads — With .. Inclusive and ..< Exclusive Everywhere

Status: Accepted Date: October 3, 2026 History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: #224 (let takes one name, and the specification writes it taking several), #209 (_ is a name, and let _ = f() lowers to Rust’s discarding let _ =), #218 (match lacks a tuple, an or-pattern, a range, a guard, a nested pattern and a struct’s rest), whether a match must cover every case, the question what a catch-all arm is written with, and #168 (a match that binds a field of a name moves the name) Related: ADR-292 (overlap { … }, whose tuple a let takes apart); ADR-149 (a channel’s (tx, rx), the other site of D1); ADR-037 D6 and D7 (the sharing floor a destructure leaves alone); ADR-279 D2 (the parse-error note D5 copies); ADR-094 D2 and D4 (a copy is the program’s to write; a let over a place is a view, which D8 keeps); ADR-277 (function types, whose lambdas D6 lets ignore an argument); ADR-276 and ADR-298 D1 (what a keyword costs); ADR-132 (else if, and what else reads as); ADR-140 (one meaning, one spelling); ADR-293 (refines D13: a range is a value that may be named and is walked as a sequence); ADR-282 (follows D14’s shape: a withdrawn spelling is refused naming its replacement); ADR-023 D1 and ADR-024 D1 (an error type is an enum; the ledger, and the absence of a claim); ADR-203 (an enum’s variants in the ledger, so D19 reaches a package’s enum, and a match over a catch’s error); ADR-280 (a generated sum, covered by D19’s rule); ADR-293 D32 (what is handed over, which D23 reads); ADR-282 D7 (no copy the program did not write); ADR-246 (a match through a boxed part); Part I 2.1, 3.3, 3.4, 6.5, 8.1.2; Part II 12.5; Part III C.1 and C.4

1. Question

What may a program write where a value is taken apart — at a let and at a match arm — and what does each form promise?

Six questions meet at that place:

They are one record because they are one construct read from two ends: what a pattern may be, which character means what inside it, when a set of arms is complete, and what a binding takes.

2. How others do it

Language Its answer What transfers Source
Rust Full patterns at let and match; _ is the wildcard and let _ = x drops a temporary at once, which is how a guard’s release is timed; _name silences the unused warning; a..b exclusive, a..=b inclusive, and only ..= in a pattern; a guard is if; a non-exhaustive match is refused; a match on a place binds by value and moves The lowering of every shape here is Rust’s, verbatim; the coverage rule is the same, so the two answers agree. Not the word wildcard, not let _ =, not the range spelling, not moving by default D1, D7, D13, D19, D23
Kotlin, Swift a..b includes its end and a..<b excludes it The pair D13 takes, read the same way D13
Every neighbouring language A guard is if D11 D11

3. Options, weighed

Question Rejected Why it lost
How much a let takes apart A pattern language at let match has patterns; neither site needs them, and a bigger answer is a decision rather than the repair (D1)
  A second statement variant Additive and cheaper, and a walk that has to remember it is a walk that will forget it (D2)
  Comparing a tuple’s width with the names A value this compiler cannot see is ?; refusing it would refuse a correct program (D3)
  Ignoring let (a, b): T It drops something the author wrote (D4)
What _ is A name, as it was let _ = f() then means discard below and bind above
  Rust’s let _ = expr to time a drop The one thing it is used for is the one thing a reader cannot see in it; a resource is closed by name or ends with its scope (D7)
  _name for bound but unused No warning to silence; a name not meant to be read should not be there (§6)
A guard’s word where A second word for a condition, ADR-276’s cost for nothing (D11)
A range pattern Rust’s ..= in a pattern, .. exclusive elsewhere A reader from Rust looks for ..=, and 1..5 would mean one thing here and another there
  .. exclusive in an expression, inclusive in a pattern Both forms parse in both places and mean different things; the compiler cannot help a reader hold the rule (D13)
  Keeping ..= beside an inclusive .. Two spellings of one range, the defect ADR-140 removes (D14)
  ..< in a pattern A pattern stays one shape; the end is moved instead (D13)
  Migrating over several changes The old spelling keeps parsing and changes meaning, so the corpus would mean something nobody wrote in between (D15)
The catch-all arm _, as before Nothing arrives at that arm, so the ignore pattern’s character says the wrong thing; two spellings would be ADR-140’s state again (D16)
  A new word else is reserved and already means the branch taken when nothing before it matched (D18)
Coverage Leaving it to rustc The reader gets the backend’s words on a Nikaia line, Part III C.1’s class (D19)
  Refusing where the type is unknown A refusal on a guess is a correct program refused, C.4’s worse mistake (D21)
An arm’s bindings match &s Every binding a reference, numbers too: r > 0 compares &i64 with i64, and the arms need a * the source does not write
  match s.clone() A copy the program did not write (ADR-282 D7)

The number that decided: no .nika file in examples/, tests/ or crates/nikaia-std/ wrote a bare _ or a ..= range, which is what made D7 and D14 free.

4. Decision

D1 — A let takes one name, or a flat tuple of names — not a pattern language.

Changed by ADR-356 D1: a let and a for head take a nested tuple of names and _; still no variant.

let (user, rights, prefs) = overlap { … }          // Part I 8.1.2
let (tx, rx) = channel::bounded(100)               // Part II 12.5

What the two sites need is taking apart a tuple whose arity is known; match is where patterns are.

D2 — One form of let, read by every reader of the tree; an analysis that cannot answer for several names declines.

A let holds a list of names, so every walk of the tree has to say what it does with several. The checker binds each name to the part at its index, or to ?; the emitter writes Rust’s tuple pattern; views marks every name a carrier. Three decline, each on the safe side: contracts::order goes opaque rather than reorder a statement binding several names, contracts::sharing records no handle so each part stays at ADR-037 D6’s atomic floor, and dsl skips, because a deferred block is one value.

D3 — A tuple’s width is not compared with the names.

Changed by #704: where the width is known for certain - a tuple of another width, or a value that is certainly no tuple - the pattern is refused, NK1283.

A value whose type this compiler cannot see is ?, and refusing a destructure of one would refuse a correct program (Part III C.4). The day a tuple’s width is known for certain is the day to refuse it here.

D4 — A written type on a destructuring let is refused, NK1136.

let (a, b): T = … cannot say which name T is about; refused rather than ignored.

D5 — A nested tuple at a let is refused with a sentence that names the shape.

Changed by ADR-356 D1: a nested tuple is taken apart; a variant in the head is NK1259 (D2).

It does not parse, and the parse error carries a note saying that the shape is what is missing, recognised from the rendering as in ADR-279 D2.

D6 — _ is the ignore pattern, and it stands where a name would be bound.

let (name, _) = pair()                      // a position of a destructured tuple
fn handle(event: Event, _: Context) { … }   // a parameter a shape dictates
xs.map fn(_, value) { value * 2 }           // a lambda's argument
match point { (0, _) => "on the axis", else => "off it" }   // a part of a pattern

_ means ignore a value that arrived — neither bind it nor use it — and is not called a wildcard. It is not a name: _count, _0 and the _000 of 1_000 still are. Two _ in one list are fine, and a parameter written _ still carries its type. The whole match arm is not one of its positions: that arm is else (D16).

D7 — let _ = expr is refused, NK1144, and _ is never a value.

A call made for its effect is written as the call; a resource is closed by name or ends with its scope (Part I 6.4). x + _ does not parse.

D8 — What _ ignores is not moved, and a temporary it belonged to ends with the statement.

let (a, _) = place binds a and nothing else, so ADR-094 D4’s view rule holds: where a is only read, the place is not moved.

D9 — _ lowers to Rust’s own _, so no binding exists below to warn about.

An ignored parameter is _: Context in the ledger’s signature column, and its keeps answer is never kept, so a caller lends it.

D10 — match takes six more shapes: a tuple, an or-pattern, a range, a guard, a nested pattern, and .. for a struct’s rest.

match point {
    (0, 0) => "origin",                       // a tuple
    (0, y) | (y, 0) => "on an axis",          // an or-pattern
    (x, y) if x == y => "diagonal",           // a guard
    else => "elsewhere",
}

match code {
    200..299 => "ok",                          // a range, inclusive at both ends
    else => "other",
}

match event {
    Event::Click(Point { x, .. }) => x,         // nested, and `..` for the rest
    else => 0,
}

A tuple pattern’s parts are patterns, so nesting is recursion. Every alternative of an or-pattern binds the same set of names, NK1155, because the arm’s body does not know which one matched. The guard stands on the arm, not on an alternative.

D11 — A guard is if.

D12 — .. in a pattern is inclusive at both ends.

200..299 matches 200 through 299: a pattern is a set of values, with no counting to n in it.

D13 — So .. is inclusive everywhere, and the exclusive range is ..< — in a for, a slice and anywhere else, never in a pattern.

for i in 0..<n stops at n - 1. One meaning per spelling, after Kotlin and Swift. A pattern that wants up to, not including moves its end: 200..298.

D14 — ..= is refused, naming 0..n.

.. says what ..= said; keeping it would be two spellings of one range.

D15 — The migration of every range is one change, with the pages and the corpus in it.

Every a..b that meant the exclusive range became a..<b at once, because the old spelling keeps parsing and changes meaning.

D16 — A match’s catch-all arm is else, and _ in that position is refused, NK1150.

match code {
    200 => "ok",
    404 => "not found",
    else => "other",
}

The message names the replacement.

D17 — _ keeps every other position: a tuple’s, a parameter’s, and a part inside a pattern.

These are the positions where a value did arrive (D6).

D18 — else costs no word, and lowers to Rust’s _.

It is reserved already and already reads as the branch taken when nothing before it matched (ADR-132).

D19 — A match covers every case, and the refusal is this compiler’s, NK1151.

A match over an enum is complete when every variant is named, and needs no else; an or-pattern covers one case per variant it names. Over anything else it needs an else (or a bare name, D20), because the set is not enumerable in arms — bool is written out as the case that is neither: true and false are a complete match. A guarded arm covers nothing, since the values its guard turns away reach the arms below. NK1151 names what is missing: the variants, or else.

match op {
    Op::Plus  => a + b,
    Op::Times => a * b,       // complete: the enum has two variants
}

D20 — A bare name is a catch-all, and covers.

other => … binds and matches anything, read by the same rule as else.

D21 — Where the scrutinee’s type is not known, nothing is claimed.

The backend’s answer stands until the type is known (Part III C.4). This is the one place the rule is weaker than it reads.

D22 — Over a place the function owns, an arm’s bindings are typed from the variant.

A name, or a field of one, that is not itself a view: a positional variant’s parts in order, a variant with named fields by name, a library’s variants from its ledger entry. A temporary and a view’s parts are unchanged. A binding the variant does not type — inside a nested pattern or an or-pattern — stays as it was.

D23 — A part that does not copy and that the arm only reads is lent, so the place is whole after the match.

It is ref n below. The arm keeps the part — and takes it — where it hands it to something that keeps it (a push, a field, a rename, a callee whose keeps says so, ADR-293), calls a method on it that changes or takes it, assigns to it, or returns it or hands it back as the arm’s value. A number, a bool and a char are copied as before.

D24 — The typing is the checker’s everywhere it reaches.

A method on a bound part resolves, so what it pauses, fails with and touches is counted where it was unknown.

D25 — A field in a struct pattern takes a pattern: field: pattern, and field alone is field: field.

Point { x: 0, y } matches where x is 0 and binds y; Move { x: px, .. } binds the field x to the name px; Shape::Circle { center: Point { x, .. }, .. } reaches inside. The part after the : is any pattern, read as D10’s parts are: a bare name binds, a path with :: names a variant, a literal is a value.

A variant’s parts are destructured the same way whether they have names or positions: a tuple’s parts are patterns (D10), and so are a struct’s fields. The shorthand alone left a struct variant’s fields unable to be tested against a value or reached into, so Expr::Binary { op: BinaryOp::Add, lhs, rhs } had to be a guard or a second match. It also could not bind two fields of the same name in one pattern ((Pair { name: a, .. }, Pair { name: b, .. })), and an or-pattern over differently named fields could not bind one name for both (Shape::Circle { r: size } | Shape::Square { side: size }, D10’s NK1155 rule). Every language with record or struct patterns has this form: Rust ({ x: px, y: 0 }), OCaml ({ x = px; y = 0 }), Haskell, Scala and Python (Point(x=px)), C#, JavaScript ({ x: px }). It lowers as Rust writes it.

Rejected:

The cost: in a pattern, x: px reads the other way round from a struct literal, where x: px gives the field the value px. Rust and JavaScript have the same asymmetry.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.