ADR-279: ?? Takes One Value as Its Fallback and Has No Postfix Form — and It Lends Its Left Side Where the Answer Is Read and Takes It Where It Is Kept

Status: Accepted Date: October 3, 2026 (D12: October 4, 2026, #342; D13: October 6, 2026, #476) History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: #214 (is there a shape where both readings of a ?? 0 > 3 type-check); the postfix unwrap in lookup(a.query("id")??), measured in #191 and asked in open-decisions.md (does the language have a postfix unwrap); open-decisions.md’s Whether ?? takes its left side, where the owner chose option (1); what ?? does with a value of a type that copies when the answer is only read, where the owner chose B.b; and what let s = c.signature ?? return false means where c is only borrowed (#125), where the owner chose B Related: ADR-278 D15 (where ?? sits, and that it chains right-associatively), ADR-298 D1 (a narrowing is reversible and its absence is not), ADR-005 D7 (a refusal explains itself), ADR-276 D20 (the four jumps are expressions), ADR-293 D6 (panic), ADR-094 D1 (whether an argument is lent is the function’s to decide, not the call’s), ADR-278 D20 (?? joins two views; an owned fallback beside a view is NK1185), ADR-293 D4 (a map read on the left is a view of the map), ADR-293 D32 (NK2106: a part of something lent handed to what keeps it), ADR-233 D4 (a view of a value that copies is read as the value), ADR-152 D2 (an array copies as its elements do), ADR-283 D3 (the compiler inserts no copy of a value that does not copy), ADR-294 D3 (a compiler’s gap is closed in the compiler, not gone round in the program), ADR-258 (the four weights)

1. Question

What is a ?? b: what may stand on its right, may it stand without a right side at all, and what does it do to the value on its left?

Part I 3.5 defines ?? as null coalescing — a value on the left, a fallback on the right, a ?? b ?? c taking the first that has one. Five questions sit at the edges of that sentence:

The first two decide the operator’s syntax, the last three what it does to its left side. A reader of ?? needs all five at once, which is why they are one record.

2. How others do it

Where the fallback ends. Swift puts ?? above comparison, so a ?? x == y reads (a ?? x) == y; C# and Kotlin put it below, so it reads a ?? (x == y). The languages disagree, so any precedence misreads for somebody (from memory, not checked against a version).

A postfix unwrap is not compared there; the argument is internal: Part III A.2 lists what ends a program, and everything on it is written as a word (D3).

A value that copies, read through ??:

Language / tool Its answer Source
Rust the program chooses: opt.unwrap_or(x) copies, opt.as_ref().unwrap_or(&x) lends; Clippy advises by size (trivially_copy_pass_by_ref, large_types_passed_by_value, default 256 bytes) Clippy’s lint list; limits from memory
C# ?? on a nullable struct copies; in and ref readonly avoid it; guidance keeps structs at 16 bytes or less .NET design guidelines, from memory
Swift value types copy by meaning; the optimiser removes copies it can prove unneeded; large storage is copy-on-write from memory, not checked
C++ c ? a : b over two lvalues is an lvalue; bound to const T&, nothing is copied the standard, from memory
Kotlin, Java objects are references; the question does not arise -

A borrowed left side beside a jump: Rust’s let Some(s) = &c.signature else { return false }; binds &Sig by its default binding modes; Kotlin’s val s = c.signature ?: return false binds the same reference; Swift’s guard let and C#’s pattern on a struct copy. None refuses the program.

What does not transfer. Here the program does not write whether a value is lent: that is the compiler’s (ADR-094 D1). So the compiler has to answer what Rust and C++ leave to the program and Swift to its optimiser.

Measured, the number that decided D8: under rustc -C opt-level=3, an 8-byte struct lent and copied compile to the same instructions; a 512-byte struct lent is 4 instructions, copied it reserves 512 bytes and calls memcpy at every read.

3. Options, weighed

Question Option rejected Why it lost
the fallback’s reach raise ?? above comparison (Swift’s reading) C# and Kotlin read it the other way, so somebody is wrong and gets no message; a precedence cannot be un-chosen once programs rest on it, while a narrowing can later be relaxed into one (ADR-298 D1)
the fallback’s reach leave it unbounded a silent wrong value (a ?? x == y)
postfix ?? a postfix unwrap one spelling, two unconnected meanings; and the second is an abort written as punctuation, where everything that ends a program is a word
an owned left side take it everywhere the program pays for keeping the name even where the answer is only read
a copy type read A: copy every copy type a memcpy per read of a large struct, a size the program cannot see at the use
a copy type read B.a: copy only numbers, bool, char lends a 1-byte enum, the one case where a reference only costs
a copy type read B.c: copy up to 16 bytes, by a computed size buys nothing measurable over B.b, and computes a size the language below does not promise for its own layout
borrowed left beside a jump A: refuse it refuses a line whose only meaning is a view; the copy or helper it forces is what ADR-294 D3 says a program should not write

ADR-258’s four weights point one way for the lending rule: only a program that keeps the answer and uses the name again pays (D1); the developer writes nothing in either common shape (D2); neither common shape copies (D3); it is the argument rule and the let rule (D4).

4. Decision

D1 — The fallback takes one value. A composite one is written in brackets.

The fallback of ?? is one value — a literal, a name, a call, a field, a -1, a jump, or an expression in brackets — and no binary operator reaches it. a ?? 0 > 3 is refused; (a ?? 0) > 3 and a ?? (0 > 3) both parse, so the refusal is a fork and not a dead end. The chain survives: a ?? b ?? c is still right-associative (ADR-278 D15), because the rule recurses on its own tail. Where ?? sits is not moved.

D2 — The refusal says why, from what a reader sees.

The parse fails after the fallback has succeeded, in whatever rule enclosed it, so the backend’s message lands on a token the author did not type wrong. A note is added where a source line has a ?? left of the caret and a binary operator at it: it names both readings and says to bracket the one meant. It shows the shape, not the offending token, which the backend reports by its first character (= for ==). A note that does not appear adds nothing and corrects nothing, which is the safe way for it to be wrong.

D3 — There is no postfix ??, and the refusal names the three spellings.

a?? is refused. It is not ?? with an argument missing but an unrelated thing wearing its punctuation, and what it would mean is an abort written as a second question mark. The three things it is reached for each have a spelling, and the refusal names all three:

what is wanted how it is written
a fallback value a ?? b
a jump a ?? throw NotFound, a ?? return r (ADR-276 D20)
I know it is there a ?? panic("…") (ADR-293 D6)

D4 — A handler’s absent query parameter reads lookup(a.query("id") ?? return bad_request()).

A handler that meets an absent query parameter answers, and the jump form says so in one line with no abort in it.

D5 — The use decides: the left side is lent where the answer is only read, and taken where it is kept.

A name on the left of ?? follows the rule an argument follows (ADR-094 D1):

Two exceptions narrow this: a value that is a word by its kind is copied at a read, not lent (D8); a left side that is itself only borrowed, beside a jump, is a view in a let too (D10).

D6 — All four weights agree.

Only a program that keeps the answer and uses the name again pays, which is what let b = a already makes it pay; nothing is written in either common shape; neither common shape copies; and it agrees with ADR-278’s ?? joins two views.

D7 — A fallback is lent only where a view of it lives as long as the answer.

A text literal is a view already, a name is viewed where it stands, a variant of an enum the file declares is a constant, and a jump leaves no value at all (D10). Any other fallback — a call, an expression that builds a value — takes the left side, the direction that cannot be wrong below.

D8 — At a read, a value that is a word by its kind is copied; every other type is lent, at every size.

A word by its kind is a number, a bool, a char, an enum whose variants hold nothing (and a T? of one), or a type the ledger says copies (winnow_grammar::Symbol); such a value is copied, as a number is (ADR-233 D4). Every other type — one that moves, and a copy type made of parts: a struct, a tuple, an array, an enum with a payload — is lent. No size is computed, so the rule is visible in the type and cannot disagree with the layout below.

D9 — A copy type handed on whole is not taken.

NK2105 and NK2106 stay silent about a copy type handed on whole, as for a number. D8 is about the read position only.

D10 — A borrowed left side beside a jump is a view wherever the answer stands.

Where the left side is a name or a field rooted in something only lent — a ref parameter, a for over a place, a let over one — and the fallback is return, throw, continue or break, the answer is a view of the left side, in a reading position and in a let alike: .as_ref() below, .as_deref() for text. A left side the function owns keeps D5.

D11 — A view kept past the loan is refused as any view is.

Returned, stored or handed to what keeps it, the view of D10 meets the refusals every view meets, with .clone() as the copy.

D12 — ?? after a type that cannot be absent is refused, and the type decides, not the shape.

Added October 4, 2026 (#342). Changed by D13: refused only after a list’s index; elsewhere a warning.

fn third_is(xs: ref Vec[i64], n: i64) -> bool {
    let third = xs[2] ?? return false   // NK1211: a list's index is an i64
    return third == n
}

let names: Vec[String?] = ["a", null]
let shown = names[1] ?? "none"          // fine: the element is a String?

A list’s index is a T, not a T? (Part I 4.5): a read past the end stops the program and does not come back empty. So ?? after one has nothing to fall back from, and neither has ?? after any other value whose type is not T? (let x: i64 = 5, x ?? 0). Left to the language below, such a line is refused in words about code nobody wrote (Part III C.1); refused here, it is NK1211, the counterpart of NK1121 for ?., asked only where the left side’s type is known (C.4). After a list’s index the help names what the line most likely meant: check the length first, if i < xs.len().

The type decides. An index into a list of T? is a T?, and ?? after it replaces a null element - a legal line that stays legal. An index past the end of that list still stops the program: ?? replaces null and never guards an index.

Why not make a list’s read a T?, as a map’s is. It would change what xs[i] means everywhere, also where nobody writes ??, to admit a line whose author was thinking of a map. A read that may come back empty is a method of its own, added when a program asks for one.

D13 — ?? or ?. after a value that cannot be absent is a warning; after a list’s index it stays refused.

Added October 6, 2026 (#476). Changes D12 and ADR-278 D13.

let s: String = "a"
println(s ?? "b")                    // NK1216, a warning: prints "a"
println(s?.len())                    // NK1217, a warning: s.len()

let third = xs[2] ?? return false    // NK1211, refused: xs is a Vec[i64]
let name  = users[2]?.name           // NK1121, refused: users is a Vec[User]

A ?? whose left side’s type is not T? builds with the warning NK1216: the answer is the left side, the fallback is checked as any expression is and never runs, and the help says to remove it. A ?. through a receiver whose type is not T? builds with the warning NK1217: it is the plain ., and its answer has the member’s type, not a T?.

Where the left side or the receiver is an index into a list whose element is not a T? (Part I 4.5), the line is still refused - NK1211 for ??, NK1121 for ?. - and the help names the length check. There the line promises a guard it does not give: a read past the end stops the program, and the ?? or ?. is never reached.

Why a warning. A type becomes narrower when what produces it learns more: a pub fn that returned User? returns User, a driver that sees WHERE email IS NOT NULL declares the column String. That is an improvement, and a refusal turns it into a broken caller. A superfluous ?? or ?. changes no value. Kotlin’s ?: and Swift’s ?? on a non-optional left side are warnings for the same reason; C# refuses ?? on a value type (from memory, not checked against a version).

Rejected: keeping both refusals, which makes a narrower type upstream a broken build downstream; and a warning everywhere, which lets xs[2] ?? return false build and stop the program where it promised not to.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.