ADR-382: A Test That Proves a T? Is Not null Makes It a T - for a Name and for a Path of Fields from It

Status: Accepted Date: October 27, 2026 History: the record this one replaces is archived in docs/history/adr; read it only for how the decision was reached Answers: whether x != null before a use changes what x is at the use, and the question the replaced record left open: whether the same holds for a field (if self.f != null { self.f.len() }) and for the value an enum holds Related: ADR-140 (one spelling for one thing); ADR-258 (the four weights); ADR-281 (a lock’s value is reached only through its doors); ADR-305 (the backend’s edition); ADR-306 D17 (a field is a name of the walk while nothing can change it); ADR-352; Part I 2.3, 3.5, 6.5; Part III 17.1

1. Question

Is x a T after a test proved it is not null? T? is a type of its own with none of T’s operators (Part I 2.3), so x > 3 over an i64? is NK1102. Without a rule the test is a bool like any other, and the program writes (x ?? 0) > 3 beside a test that has already ruled the fallback out.

And for a field? Most T? a program tests are fields: self.cache, node.next, p.name. if p.name != null { p.name.len() } is NK1125 until this record, and a program writes let n = p.name first, a second name for a value that already has one. Rust code moving into Nikaia (if let Some(x) = &self.f) meets this on nearly every page.

2. How others do it

Language / tool Its answer What it costs there Source
Kotlin smart cast of a local, and of a val property without a custom getter declared in the same module; never of a var property a var field needs ?.let { } or a local copy: another thread or an alias may write it between test and use Kotlin docs, Type checks and casts (from memory, not checked)
TypeScript narrows property paths (a.b.c); an assignment to the path or a prefix resets it, a call does not unsound: a call that writes the field through an alias keeps the narrowed type TypeScript handbook, Narrowing (from memory, not checked)
Swift no narrowing; if let x = self.f, guard let, binding a new name a second name for every test; the original stays optional Swift book, Optional Binding (from memory, not checked)
Rust no narrowing; if let Some(x) = &self.f, the borrow checker forbids a write to self.f while x lives a binding per test; the borrow rules do the proving Rust reference, if let

What does not transfer. Kotlin’s refusal of a var field is about the JVM: any other reference to the object, in any thread, may write it at any time. A Nikaia value has one owner, a mut view is exclusive, a shared value is reached only through a lock’s doors under a name of its own (ADR-281), and a task sees only what is moved or shared into it. So what can write a field between test and use is visible in the function itself: a write to the path, or a mut hand-over of it. That is the reason TypeScript’s syntax can be had here with Rust’s soundness.

3. Options weighed

A: nothing changes. Every use writes ?? with a fallback that cannot run.

if x != null { use((x ?? 0) + 1) }

B: names only. A local or a parameter narrows; a field is copied first.

let name = p.name
if name != null { print(name.len()) }

C: names and paths of fields from them; the proof ends at what can write the path.

if p.name != null { print(p.name.len()) }

D: as C, but every call ends a path’s proof (Kotlin’s line, made stricter).

if p.name != null { log("x"); print(p.name.len()) }   // NK1102 under D

E: a binding in the condition, if let v = x { … } or if x is v.

if let name = p.name { print(name.len()) }
Option D1 the special case pays D2 compiler works D3 no cost common case D4 consistent If it is wrong
A no: every test pays a dead fallback no no yes dead code reads as if it mattered
B the field pays a copy for names names yes, fields no no: a field is a name for ADR-306 D17 but not here one let too many per field
C yes: only a path with a write in between loses the proof yes: the walk ADR-306 D17 already runs yes yes: the same path rule as D17 NK1102 naming the write; the program adds a let
D no: an unrelated log call ends the proof yes no: most bodies call something no: D17 keeps a fact across a ref call noise refusals
E no: a second name per test yes no no: a second spelling of a match arm (ADR-140); covers neither \|\| nor an early exit two ways to say one thing

C is chosen.

4. Decisions

D1 — What the compiler can prove, it uses.

A condition that proves a local, a parameter, or a path of fields from one (D4) is not null makes it a T wherever that proof holds, and only there. Elsewhere it is the T? it was declared, and an operator of T over it is NK1102.

Why. Nikaia proves what it can rather than check it at run time or leave it to the author: an index the compiler proves in bounds is not checked (ADR-306), a division by a divisor proved not zero is not guarded. A test x != null is the same kind of knowledge, stated by the program itself. Where the compiler sees it, the code after it cannot meet null, so x > 3 there is correct and refusing it is noise; where it cannot see it, the refusal stays. Internally the name has another type at that point, which is all the rule is: the type follows what is proved.

D2 — Where a test proves it.

Nothing else proves: || over a test that does not say null proves nothing, nor does a test of another name or another path. The same places hold for a path: if p.next == null { return } makes p.next a T for the rest of the block.

Why. These are the places the test runs before the read on every path. They are the ones Kotlin, TypeScript and Swift’s guard cover, and each follows from how &&, || and a jump already run.

D3 — A write takes the proof back.

A name assigned between the test and a use is a T? again from the assignment on, and a use after it that needs a T is NK1102, with a note and a label naming the assignment. A loop opened inside the proven region that assigns the name takes the proof back for the whole loop, since its next turn reads the name after the write.

Why. The proof is about the value the test saw. A write puts another value in its place, which may be null.

D4 — A path of fields from a local or a parameter narrows, at any depth.

p.name, self.cache, a.b.c: a path that starts at a local or a parameter (self included) and goes on through fields only is proved and taken back like a name. Two paths are the same path when they name the same root and the same fields in the same order.

Not a path: anything through an index (v[i].f), a call result (f().x, m.get(k).f), or a lock’s value. The first two are computed anew at each read, so a test of one says nothing about the next; a lock’s value is read only inside its door (ADR-281), where the door’s own name is a local and its paths narrow. Such a test proves nothing, and a use that needs a T is NK1102 with a note that the read is not a path.

Why. It is the path ADR-306 D17 already calls a name for a bound or a length; a null test is one more fact about the same path, kept by the same walk.

D5 — What ends a path’s proof: what can write the path.

A path’s proof holds until the first of:

An assignment to a sibling (p.age = 3 keeps p.name) and a call that only reads (ref or a copy of the root, or a call that is not handed the root at all) keep the proof. A loop inside the proven region that does one of these takes the proof back for the whole loop, as D3. A use that needs a T after it is NK1102, with a note and a label naming the write.

Why. A ref view cannot write, a mut view is exclusive, and a field is never shared between owners, so only these can put another value at the path between test and use. The rule is ADR-306 D17’s, so a bound and a null fact about the same path end at the same place. A parameter the function only borrows keeps its paths’ proofs for the whole region, as D17 keeps its facts: nothing in the body can be handed it to change.

D6 — An enum’s value is bound by match only.

A binding in a condition, if let v = x { … }, if x is Some(v) or if e is Leaf(v), is refused. match binds the value an enum or a T? holds, and a T? tested with != null needs no binding (D1).

Why. It is a second spelling of a match arm (ADR-140: one thing, one way), it adds a name for a value that already has one, and it covers neither || nor an early exit. This closes what the replaced record left open about enums.

D7 — let x = self.f stays, and is needed only to keep the old value.

Copying a field into a local before a test is allowed, as any let is, and narrows the local by D1. It is needed only where the program must keep the value across a write to the path (D5); elsewhere if self.f != null says it.

Why. A rule that made the copy necessary would repeat B’s cost; one that forbade it would forbid keeping a value.

5. Consequences

6. What this does not decide

A path through an index whose index is itself proved unchanged (v[i].f with v and i not written) is left out; it may join D4 when a program needs it.

This site is open source. Improve this page.