ADR-278: T? Is a Type the Compiler Wraps Into, and ?. Reaches a Member Through a View of Its Receiver

Status: Accepted Date: October 3, 2026 History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: Part I 2.3’s own example, which did not compile (&str? was a parse error, null a name); the owner’s question whether ?. reaches a method; the unwrapped return of a value whose type is not known; #192, ?. takes the value it reaches through; and the former open-decisions.md’s what does ?? mean when its left side is a view?, where the owner took option A Related: ADR-283 D2 and D3 (the tether-state lattice; a copy is never inserted) · ADR-288 (the emitter has no types, so every which shape is the checker’s) · ADR-010 D1 (the fail-closed direction) · ADR-298 D1 (null is reserved) · ADR-279 D1 (a ??’s fallback is one value, or brackets) · ADR-094 D4 and ADR-282 (a value’s state is the compiler’s per use) · ADR-185 D2 (a binding written for the language below) · ADR-282 D8 (.clone() is the copy) · ADR-279 (what ?? does to an owned or borrowed left side) · ADR-293 (a map of T? values reads one T?) · ADR-302 (a C handle may be absent, and is a T?) · Part I 2.3 and 3.5, Part III 15.2, C.1, C.4

1. Question

How does the language write the absence of a value, how does a program reach a member of something that may be absent, and what does that reach do to the thing it reaches through?

Part I 2.3 says types are non-nullable unless a trailing ? says otherwise, and Part I 3.5 gives two operators: ?. reaches a member only where the receiver is not null, and ?? supplies a fallback. Each sentence left questions the compiler had to answer: who writes the backend’s Some(…), what to do where the value’s type is not known, whether a method is a member, what a plain . on a T? means, and whether ?. moves its receiver.

2. How others do it

Language Its answer Source
Rust Option<T>; the constructor Some(x) is written by the program; From<T> for Option<T> and the identity From<T> for T both exist, so x.into() against a declared Option<T> is right whether x is a T or already an Option<T>; Option::map takes its receiver by value, as_ref() borrows it; a reference into a temporary bound by a let is temporary value dropped while borrowed, with the help consider using a let binding measured against this repository’s toolchain (D7, D21)
Kotlin, C#, Swift, JavaScript ?. reads through its receiver and leaves it where it was; ?. guards its own member only; ?? / ?: is right-associative not re-checked (D14, D15, D16)
JavaScript, C# null inhabits every reference type, so a?.b.c with a.b absent is a run-time TypeError / NullReferenceException D14

What does not transfer: in those languages a reference is shared and collected, so reads through the receiver costs nothing to say. Here whether the result is a copy, a view or owned is a state the compiler solves (ADR-283 D2), and a copy is never inserted (D5) - so each shape of ?. had to be measured for which state it needs.

3. Options, weighed

Question Rejected option Why it lost
Who writes Some(…) the program writes it there is no Some in Nikaia; the type’s purpose would become paperwork (D5)
A value of unknown type into a T? leave it alone the program fails in the language below, try wrapping in Some, about a form Nikaia lacks (D7)
  refuse it refuses correct programs (C.4)
  .into() everywhere right, but Some(x) says what the source means where the type is known (D6)
?.m() refused, as not named by 3.5 3.5’s word is member, and a method is one (D10)
Lowering a method reach the field’s map an .await does not compile in a closure and a ? has nowhere to go (D12)
a?.b.c read as a?.b?.c one ?. would silently govern a chain of any length (D14)
What ?. does to the receiver take it every language with ?. leaves the receiver (D16)
A non-copy member wait on the tether state (#196) measured: no ?. shape needs it (D17)
  refuse it would refuse find(1)?.name ?? …, which compiles and runs (D19)
  guard by emit::is_a_place asks whether an expression can be written to; find(1)?.home?.city passes and roots in a call (D19)
A view ?? an owned fallback B: leave it rustc errors about a file nobody wrote (C.1)
  C: widen to owned inserts a copy, which ADR-283 D3 bans
A view out of a temporary hoist the receiver everywhere on the lazy side of ??, &&, || it would run a call that otherwise does not run (D22)

4. Decision

D1 — T? is a type, and a trailing ? is how it is written.

let mut maybe_string: ref String? = null
maybe_string = "World"

It lowers to Option<T> (Part III 15.2’s mapping, the other way round). The ? comes last: Vec[i64]? is a nullable list, Vec[i64?] a list of nullables. (A, B)? is a nullable pair (Changed by ADR-364 D1). A diagnostic says String?, never Option[String], which is a name no program can write.

D2 — The ? is a flag in the AST and a wrapper in the type.

In the AST it sits beside the view flag, because a nullable view is both and the two are independent. In contracts::ty::Ty it is Nullable(Box<Ty>), because every reader of a type already recurses, so a forgotten case is one the compiler names.

D3 — A plain T stands where a T? is wanted; nothing stands the other way.

The one widening the language has. A T? gets back to a T through ??.

D4 — null has no type of its own, and is a reserved word.

null fits anything nullable and needs no wrap. let m = null with nothing beside it is not refused: let mut m = null then m = "hi" is correct, and where nothing ever says the type, the backend asks for the annotation (C.4).

D5 — The constructor is the compiler’s to write, and the checker says where.

There is no Some in Nikaia. Whether the value is already a T? is a question about types, and the emitter has none (ADR-288), so the checker records each wrap and the emitter writes it, at five positions: an annotated let, an assignment, a return, a struct-literal field and a call argument (keyed by the statement, the field name, or the callee as written and the argument’s position).

D6 — Where the value is known not to be a T?, the wrap is Some(…).

Known by its type, or because it is a literal - a number has no type of its own (Part I 2.4), so return 42 against an i64? would otherwise go unwrapped. Some(x) stays because it says what the source means.

D7 — Where the type is not known, the wrap is value.into().

Right in both directions: Some(x) for a T, x itself for an Option<T>. The target is pinned at every position by the declared type, so it is never inferred from the value. A value already known to be a T? gets neither.

D8 — One function answers how the wrap is written, for every position.

check::wrap_for answers Constructor, Conversion or nothing, and the emitter writes both forms in one place.

D9 — ?. reaches a field, and map or and_then is the checker’s choice.

let city = find(1)?.home?.city ?? "nowhere"

map over a plain field, and_then over a field that is itself a T?, so a?.b?.c never reaches through a nullable of a nullable. Decided from the declared type and recorded per statement and name; a statement where the same name is mixed falls back to map. The result is a T? either way.

D10 — ?. reaches a method, with everything a call has.

let greeting = find(1)?.greet("Hallo") ?? "nobody"

Arguments, the ; option zone and a trailing lambda reach it; what it throws, whether it pauses and what it binds are checked by the one function that checks a plain call. A ?. decides whether the call happens, never what a call is.

D11 — A reached method’s result flattens as a field’s does.

A method whose own result is a T? gives a T?, not a T??.

D12 — A method reach lowers to a match, because a method may pause and fail.

match find(1).as_ref() {
    Some(__nikaia_it) => Some(__nikaia_it.read("note.txt").await?),
    None => None,
}

Inside a closure an .await does not compile and a ? has nowhere to go. The flattened case drops the Some(…) around the arm.

D13 — ?. through a value that cannot be absent is NK1121.

Changed by ADR-279 D13: refused only through a list’s index; elsewhere the warning NK1217, and the ?. is the plain ..

Only where the receiver’s type is known (C.4). The help keeps the spelling the reader wrote: write .name, or write .name(…) for a call.

D14 — A plain . on a T? is NK1125.

a?.b.c guards a and nothing else; with a.b absent, .c has nothing to reach on. Where null inhabits every reference type that is a crash at run time; here T? is a type of its own (Part I 2.3) and a member of T is not a member of it, so it is refused where it is written. The way out is ?.c, or ?? and then the plain .. maybe().c, with no ?. at all, is the same refusal.

D15 — ?? is right-associative.

a ?? b ?? c is a ?? (b ?? c): the last fallback ends the chain, and every ?? before it takes the T? on its left.

D16 — ?. takes nothing: the receiver stays usable.

user?.name reads user where it lies; user is usable on the next line, in a second ?., in a call.

D17 — What comes out is a copy where the member copies, and a Borrowed view otherwise.

Where the member does not copy, the result is a view of the receiver, kept alive as any view of a place is (Part I 6.6). Its state is Borrowed (ADR-283 D2’s free case): no shape of ?. escapes the receiver’s scope, so none needs the tether - a local receiver, a temporary consumed in the statement and view ?? view were measured to compile and run Borrowed at zero cost. Tethered stays for what escapes - a container of views returned past its buffer’s scope, a parser’s rows handed past it, a kept lambda capturing one. A program that keeps the result past its receiver writes the copy, .clone(); nothing is copied that it did not write.

D18 — The receiver is lent wherever the compiler knows that is right.

A field whose type the checker could not work out lowers taking the receiver - the direction that cannot break a program that worked (C.4).

D19 — A non-copy member is a view of a receiver rooted in a binding; a temporary is taken.

check::roots_in_a_binding follows the chain - a name, a field or index of one, a ? or cast over one - to its root. Rooted in a binding, the member is a view (.as_str() or &, .as_deref() or .as_ref() where it flattens). Rooted in a call, the reach takes the temporary, which has no next line to stay usable on, so D16 loses nothing there. It is not refused: find(1)?.name ?? "nobody" compiles and runs.

D20 — ?? joins two views; a view beside a fallback that owns is NK1185.

An owned result would copy the borrowed branch (ADR-283 D3’s ban), and a view result needs the fallback to be one, which a text literal already is. So the mixed form is refused, and the help is ?? "…". A copy spelled by name (.clone(), .to_string()) counts as owned even where no ledger types it. Both sides have to be known (C.4).

D21 — A view a reached method hands back out of a temporary holds the temporary.

let a = find(1)?.label() ?? "none"     // label(ref self) -> ref String

The emitter binds the receiver on the line before and the reach reads the binding, which lives to the end of the block as a name the program bound would. Nothing is copied, and nothing runs that did not: the call ran first in that statement anyway.

D22 — Only where holding it changes nothing about what runs; elsewhere it is refused.

Not on the lazy side of ??, && or ||; not in a match arm; not after another call in the same statement; not where the view is the block’s value or the function’s result. There the program is refused in this language’s words, naming the let it can write.

D23 — A move left to explain is explained in this language’s words.

Where a reach still takes its receiver (D18’s unknown field, D19’s temporary), the backend’s Option::map takes ownership is translated to ?. over a member that is not copied takes the value it reaches through; it never names .as_ref() or a clone the program did not ask about.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.