ADR-126: _ Is the Ignore Pattern — It Stands Where a Name Would Be Bound, and Nowhere Else

Status: Superseded by ADR-291 — its D1-D4 are D6-D9 there, D1 as narrowed by ADR-145 D1 Date: September 19, 2026 Target Version: Nikaia 0.0.8 Related: Part I 2.1 (names), 3.4 (match, where _ already stands), 5.3 (the one lambda form), 6.4 (Cleanup), ADR-098 (a let takes one name or a flat tuple of them), ADR-094 D4 (a let over a place is a view of it), ADR-102 (a function type, whose lambdas have to fit a given shape), Part III C.1

1. Context

_ exists in one place: a match arm, where it matches anything and binds nothing. Everywhere else a value arrives it has to be given a name, whether or not the body wants it — a tuple’s second half, a parameter a trait or a callback type dictates, a lambda’s argument a map hands over. A name nobody reads is a name a reader looks for, and in the language below it is an unused variable warning about a file nobody wrote, which is the class Part III C.1 forbids.

The word matters. Other languages call _ a wildcard, which suggests a type that accepts anything. That is not what it does here: it is an instruction to ignore a value that arrived — neither bind it nor use it — and the name should say so.

2. Decisions

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

Three positions, and only these:

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 code { 0 => "ok", else => "other" }   // see below: this one moved

A tuple position, a parameter of a function or a lambda, and — until ADR-145 — a match arm. That one is else now, for this record’s own reason: _ means ignore a value that arrived, and nothing arrives at the arm taken when none of the others matched. The two positions above it stay. Two _ in one list are fine — nothing is bound, so nothing collides — and a parameter written _ still carries its type, because the caller needs it.

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

A binding that ignores its whole value is a statement wearing a let: a call made for its effect is written as the call, f(). A resource that has to be torn down at a moment of the program’s choosing is closed by name, close(), or lives in a scope it can end with (Part I 6.4) — never by a let whose only purpose is to decide when a Cleanup runs, which is the one thing Rust’s let _ = is used for and the one thing a reader cannot see in it. The refusal is NK1144: _ ignores a value inside a pattern; write the expression as a statement, or bind it to a name.

_ in an expression (x + _) does not parse, as it never did.

D3 — What is ignored 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 rule holds unchanged: where a is only read, the let is a view of the place and the place is not moved. The value an _ ignores in a destructured temporary is released at the end of that statement, which is what a value nobody bound would have done anyway. Nothing about the doors changes: there are no lock guards to release early, because there are no first-class lock guards.

D4 — The lowering is Rust’s own _, so no binding exists to warn about.

In a tuple pattern and a parameter list the emitter writes _, which binds nothing below either; a match arm already did. The unused variable warning that a placeholder name used to earn in the generated file cannot arise, which is the C.1 point.

3. Consequences

4. What this does not decide

5. What is built

All of it, and two of this record’s own sentences were wrong about the state it was correcting: §5’s let _ = f() does not parse either and D2’s x + _ does not parse, as it never did. Both parsed. _ was an ordinary name, so let _ = f() compiled and lowered to Rust’s own let _ =, which discards the value where the source said bound, and x + _ was a name NK1117 said nothing declared. That is the defect #209 recorded and this record answers, and it is why the work below is not only an addition: a character had two readings and the compiler’s answer was an accident of what the parser happened to accept.

_ is no longer a name: NAME rejects a bare one, so x + _ does not parse and the ignore pattern is producible in exactly the three positions D1 names — a tuple position, a parameter of a fn or a lambda, and the match arm it always had. The symbol it carries is one no NAME can produce, which keeps anything else in the compiler from mistaking a name for it.

Two lookaheads in front of it, and they are what _name costs: a bare _ is one followed by neither an identifier nor a digit. So _count and _0 stay names, and so does the _000 of 1_000 — which matters, because NK1117’s a number is written in digits with no separators is a message about that name.

let _ = expr is accepted by the grammar and refused by the checker (NK1144), which is the only arrangement that can carry D2’s sentence: a refusal with a message has to come from somewhere that can write one, and a parse error at a character cannot. let (a, _) = pair() is D1’s tuple position and binds a.

The lowering is Rust’s own _, and the ledger writes _: Context in the signature column, where its keeps answer can only be not kept — a body that never names a value cannot store it — so a caller lends it and the parameter is &Context below. §3’s third bullet, arrived at rather than arranged.

Seven tests in crates/nikaia/tests/ignore_pattern.rs, and one of them is D4 through rustc itself: the emitted program is compiled without -A warnings, because the unused variable warning is what is being asserted absent and silencing warnings would make that test pass forever. Another keeps #209’s own finding — that nothing in examples/, tests/ or crates/nikaia-std/src/ writes a bare _ — which is what made this free to decide and is now what makes it free to keep.

This site is open source. Improve this page.