??’s Fallback Is One Value, or an Expression in BracketsStatus: Superseded by ADR-279 — its D1-D2 are D1-D2 there
Date: September 15, 2026
Target Version: Nikaia 0.0.8
Related: ADR-066 D4 (which put ?? where it is, and whose
chain this keeps), ADR-051 D1 (the polarity: a narrowing is
reversible and its absence is not), ADR-005 D7 (a refusal explains
itself)
?? sits above the whole binary chain — above range, ||, &&, comparison and
arithmetic — so its fallback reached rightwards across every operator there is:
let x = a ?? 0 > 3 // is `a ?? (0 > 3)`, and looks like `(a ?? 0) > 3`
#214 carried this as a suspicion rather than a defect, for a
reason that was honest at the time: the shape above produces a type error, since
a bool does not fit an i64, and whether there is a shape where both
readings type-check, so the program silently takes the other one, is precisely
what has not been established.
It is established now, and in the worse direction. With a: bool?,
x: bool and y: bool:
let a: bool? = false
let x: bool = true
let y: bool = false
a ?? x == y // false — what the program does
(a ?? x) == y // true — what the line looks like
a ?? (x == y) // false
Both readings are well typed and the answers differ. Nothing downstream would
have caught it: this is a silent wrong value, which is the worst thing in
docs/README.md’s list.
coalesce_fallback takes unary_expr rather than coalesce_expr, so a
literal, a name, a call, a field, a -1 and a bracketed expression all
reach it, and no binary operator does. The chain survives — a ?? b ?? c is
still right-associative (ADR-066 D4) — because the rule recurses
on its own tail.
Refused rather than re-precedenced, and that is the decision rather than the
cheap way out. The obvious alternative is to raise ?? above comparison, so the
line means what it looks like — which is what Swift does. C# and Kotlin do the
opposite and read it the way this language did. The languages disagree, so
whichever precedence is chosen, somebody reads the line wrong; and the reader
who is wrong gets no message at all. Making the author say which they meant is
the only answer that is right for both readers.
The polarity settles the rest (ADR-051 D1’s shape): a narrowing
can be relaxed into a precedence later, and a precedence cannot be un-chosen
once programs rest on it. And it costs nothing today — every ?? in
examples/ has a fallback that is a literal, a name or a call, measured before
this was built.
A refusal in the grammar cannot always say why, and this is the case that proves
it: a ?? 0 > 3 fails after the fallback has succeeded on 0, in whatever
rule enclosed it, so the backend’s message is about that rule’s closing brace
and the caret lands on a token the author did not type wrong.
So a note is added, from the rendered message: a source line with a ?? to
the left of the caret and a binary operator at it. It names both readings and
says to bracket the one meant. Read off what a reader sees, for the reason
reserved_word_note is — and a note that disappears is the safe way for this to
be wrong, because it adds a sentence and corrects nothing.
The offending token is not quoted back. The backend names the first character
it could not use, so a == arrives as = and a help that echoed it would read
(a ?? 0) = …. The shape is the same whichever operator it was, so the message
shows the shape.
The line that read both ways cannot be written. Either bracketing parses, so the refusal is a fork and not a dead end.
#214’s entry stops being a suspicion — cited by subject, because that page’s numbers move: it was filed with the exact question that decides it, and the answer makes it a defect that is now closed.
?? sits. D1 narrows what may follow it and moves nothing.
ADR-066 D4’s placement is untouched, and the day a bracket-free
reading is wanted, a precedence is still available — which is the direction
this was chosen to keep open.catch is the only thing
looser than ??, and whether x ?? y catch { … } reads as it looks is a
question of its own that nothing has measured.coalesce_fallback in the grammar, with the # label and the recursion that
keeps the chain; parser::coalesce_fallback_note beside reserved_word_note,
and both joined where a parse error is rendered. Exercised by five programs in
crates/nikaia/tests/coalesce_fallback.rs — the ambiguous one, both
bracketings, seven ordinary fallbacks, the chain, and a parse error that must
not get the note.