ADR-089: A ??’s Fallback Is One Value, or an Expression in Brackets

Status: 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)

1. Context

?? 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.

2. Decisions

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

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.

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

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.

3. Consequences

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.

4. What this does not decide

5. What is built

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.

This site is open source. Improve this page.