ADR-052: T? Is a Type, null Is a Word, and the Some(…) Is the Compiler’s to Write

Status: Superseded by ADR-278 — its D1-D3 are D1-D3, D4 is D5-D6, D5 is D4, D6 is D9, D7 is D13, D8 is D16 there Related: Part I 2.3 (the rule this makes true) and 3.5 (??, which was already built and had nothing to be built on), Part III 15.2 (the mapping, Rust Option<T> to Nikaia T?), ADR-051 D1 (null joins the reserved list), ADR-024 (the ledger, which writes T? and not Option[T]), ADR-028 (why the wrap is decided in the checker and written in the emitter), ADR-010 D1 (the fail-closed direction the wrap rule takes)

1. Context

Part I 2.3 says types are non-nullable by default and that a trailing ? allows the absence of a value. Neither line of its own example was accepted: &str? was a parse error, and null was read as an ordinary name.

And the state was worse than “unbuilt”, because Part I 3.5’s ?? was built — a ?? b lowered to a.unwrap_or_else(|| b.into()), an operation on an Option in a language with no way to write one. So the only values it could be used on came out of std, and the type the whole section is about could not be named by a program.

2. Decisions

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

let strictly_string: String = "Hello".to_string()
let mut maybe_string: String? = null
maybe_string = "World".to_string()

It lowers to Option<T>, which is the mapping Part III 15.2 already wrote the other way round. The ? comes last, after the type’s arguments, so Vec[i64]? is a nullable list and Vec[i64?] is a list of nullables.

Option is what it lowers to and not what it is called. A diagnostic says String?; Option[String] is a name no program can write, and a message that used it would be a message about the generated file (Part III, C.1).

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

Two representations of one thing, on purpose.

In the AST it sits beside is_view, because &str? is a nullable view and the two are independent: a wrapper there would have to be unwrapped by everything that asks whether a declaration names a borrow.

In contracts::ty::Ty it is Nullable(Box<Ty>), because every reader of a type already recurses — so a wrapper is one arm per reader instead of a condition inside each of them, and the compiler names the readers that forgot.

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

This is the one widening the language has, and Part I 2.3 is what asks for it: maybe_string = "World" hands a String to a String?. The other direction is refused, because that is the whole point of the type being separate — and ?? (Part I 3.5) is how a program gets from a T? back to a T.

D4 — The Some(…) is written by the compiler, and the checker says where.

The language below needs the constructor. There is no Some in Nikaia and must not be: a program says maybe_string = "World", and a wrapper the programmer has to write would make the type’s whole purpose into paperwork.

Whether the value beside the = is already nullable is a question about types, and the emitter has none (ADR-028). So the checker records where a wrap is needed and the emitter writes it — the same arrangement, for the same reason, as shared_sites (ADR-040 D1) and the fallible loops (ADR-025 D1).

Four kinds of position, and each is keyed by the narrowest thing that identifies it, because an expression carries no span:

where key
an annotated let, an assignment, a return the statement
a struct-literal field the statement and the field’s name
a call argument the statement, the callee as written, and the position

The written callee and not the resolved one, because the emitter has only what the source says: a method’s key is Type::method and a constructor’s is Type::new, and neither stands at the call.

Two ways a value is known not to be nullable already, and a type is only the first:

Everything else is left alone, which is the fail-closed direction (ADR-010 D1): wrapping a value that is already an Option<T> would make an Option<Option<T>>, so the set never claims a wrap that is not needed.

D5 — null has no type of its own, and is not refused for it.

null answers “a T? of it-does-not-say”, so anything nullable fits it and nothing needs wrapping around it.

let m = null on its own is not refused here, and that is C.4 rather than an oversight: let mut m = null followed by m = "hi" is a correct program, and this checker has no inference to tell it from the one where nothing ever says. So rustc asks for the annotation, which is the honest answer — this compiler cannot say what the value is the absence of, and neither can the program.

null is a reserved word (ADR-051 D1), which is what keeps the other reading away: as a name it would be NK1117, and as a name something declared it would be a different program.

D6 — ?. reaches a field only where there is something to reach it on, and map or and_then is the compiler’s choice.

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

x?.a lowers to x.as_ref().map(|it| …) where a is a plain field, and to x.as_ref().and_then(|it| …) where a is itself a T? (the as_ref() is ADR-113’s, and the member is copied or viewed as D8 says). That second one is the whole difficulty: map over a nullable field would give an Option<Option<T>>, and a?.b?.c would come out reaching through a nullable of a nullable.

Which of the two is a question about the declared type, so the checker decides and the emitter writes the word — the same arrangement as D4’s wrap and for the same reason (ADR-028). The key is the statement and the field’s name, which is the shape fallible_methods already uses: an expression carries no span, so a pair is recorded only where every ?. of that name in that statement flattens, and a mixed statement falls back to map — the answer that cannot make a nested option out of a plain field.

The result is a T? either way, which is what makes ?? the way out of a chain and lets ?. be written again after it.

D7 — ?. through a value that cannot be absent is refused.

NK1121, and the way out is the plain .:

error: `?.` reaches through a `User`, which cannot be absent
   4 |     let n = u?.name
           ^
     = `?.` exists for a nullable type — it reaches the field only where there is
       something to reach it on, and answers `null` otherwise (Part I, 3.5). A
       type that is not `T?` always has a value
     help: write `.name`

Only where the receiver’s type is known. One this checker could not work out says nothing, because refusing there would refuse a correct program (Part III, C.4).

D8 — ?. reaches through a view, and the receiver stays usable.

x?.a reads x where it lies — x.as_ref().map(…) below — and what comes out is a copy where a copies and a view of x otherwise, kept alive as any view of a place is (ADR-113). A receiver used again after a ?. is an ordinary program, and there is no move for a message to explain.

3. Consequences

Part I 3.5’s ?? has something to be used on, and needed no change: it already lowered to unwrap_or_else, and the type it operates on is now writable.

The ledger writes T?. A signature comes back as (id: i64) -> i64? — the program’s own spelling, in the file a consumer reads.

A T? crosses into foreign code exactly as its T does (ADR-045), because that is what it lowers to: an Option<T> is Send when T is, and holds one T or nothing.

(A, B)? is deliberately absent. Part I 2.3 does not write it, and a form the emitter would have to invent a lowering for is worse built than unbuilt.

4. What this does not decide

5. What is built

D1 through D5. T? parses and lowers, null is a word, and the Some(…) is written at an annotated let, an assignment and a return.

crates/nikaia/tests/nullable.rs compiles what it lowers rather than comparing strings, because whether the wrap landed in the right place is settled by the language below and reading the emitted text would only say that this compiler agrees with itself. It covers Part I 2.3’s own example, a nullable result with the wrap at a return, a value that is already nullable not being wrapped twice, the ledger round-trip, D3’s rule in both directions, and a diagnostic that says i64? and not Option[i64].

?. is covered too: map over a plain field, and_then over a nullable one, the struct-literal wrap, NK1121, that an unknown receiver says nothing, and that a ?? b is not a?.b — the grammar spells ?. as one token, so the generator cannot put the implicit whitespace inside it and a ? . b is not safe navigation either.

The harness runs the checker as well as rustc, which was added after it caught the test author: a ?. on a plain value reached the backend and came back as “User is not an iterator”, reporting the wrong thing about a program this compiler would have refused.

The wrap reaches all four kinds of position, the call argument included: shown("here".to_string()) into a String? comes out shown(Some(…)), and pair(1, 2) into two i64? parameters is told apart by position.

Nothing of this record is unbuilt. What §4 lists is one form the specification does not name and two questions it does not put.

This site is open source. Improve this page.