?? 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 KeptStatus: 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)
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:
?? above the whole binary chain,
a ?? 0 > 3 is a ?? (0 > 3) while looking like (a ?? 0) > 3. With
a: bool?, x: bool, y: bool, both readings of a ?? x == y type-check
and give different answers: a silent wrong value.??. lookup(a.query("id")??) reads like
an unwrap that the section, the parser and every rule lack; a reader who
writes it meets a parse error with nothing to look up.?? takes an owned name on its left. viewed ?? "none" over a
viewed: String? is a move below; unrecorded, a later use of viewed
reaches rustc as borrow of moved value. ADR-278 and ADR-293 decide a
view on the left, not an owned name.let s = c.signature ?? return
false over a lent c would be NK2106 - a let keeps the answer, and a
part of a loan cannot be taken - and the program would pay a copy or go round
through a helper.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.
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.
| 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).
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.
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.
??, 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) |
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.
A name on the left of ?? follows the rule an argument follows (ADR-094 D1):
print(user ?? "guest"), shown(v ?? other): the option is
opened below (.as_deref() for text, .as_ref() otherwise), the answer is
a view of the name or of the fallback, and the name is usable on the next
line. The positions that only read are a call’s argument the callee only
reads, the print calls, an f"…" hole, a comparison of text (a
comparison over anything else would need a reference on the other side the
source did not write, and takes), and the receiver of a method every
candidate of which only reads it.let name = user ?? "guest", return user ?? "x", a field: the
name is taken, as let b = a takes a; a later use is NK2105, whose way
out is .clone().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).
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.
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.
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.
NK2105 and NK2106 stay silent about a copy type handed on whole, as for a
number. D8 is about the read position only.
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.
Returned, stored or handed to what keeps it, the view of D10 meets the
refusals every view meets, with .clone() as the copy.
?? 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.
?? 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.
?? keeps one meaning; a reader who tries a postfix ?? is told the three
spellings. An abort through ?? is always written ?? panic("…"), in words.?? does to its left side: lent at a read, taken
where kept, a view beside a jump over a loan. NK2105 after a ?? means
the answer was kept, which is the one shape where the program writes
.clone().?? is lent; a word is copied.let s = c.signature ?? return false over a lent c compiles and binds a
view, with no helper or copy written for it.reserved_word_note, and
the checker asking the kind of a type, which it already knows.?? sits. D1 narrows what may follow it and moves nothing; a
precedence is still available the day a bracket-free reading is wanted.catch is the only thing
looser than ??; whether x ?? y catch { … } reads as it looks is not
measured.?? panic("…") is not enough.??. Its own record, if a program measures a
cost there.