T? Values Reads One T?, and Writing null Stores ItStatus: Superseded by ADR-293 — its D1-D4 are D12-D15 there
Date: October 3, 2026
Answers: what a read of a map whose values are themselves T? is - a
stored null beside an absent key - asked in open-decisions.md when moving
the compiler’s sharing analysis into Nikaia (#125) met it
Related: ADR-114 D1, D4 (a map read through the brackets is
a T?, and it is a view of what the map keeps); ADR-052 (T? is
a type and null a word); ADR-080 D2 (a write through the
brackets is an insert); ADR-233 D4 (a view of a value that
copies is read as the value); Part I 2.3 (?. never reaches through a
nullable of a nullable; (A, B)? is not written)
A map may hold T? values: BTreeMap[i64, Kind?] with m[2] = null. A read
through the brackets is a T? (ADR-114 D1), so a read of such a map is a T?
of a T?. Is it one T?, in which a stored null and an absent key are
the same answer, or are the two kept apart?
Without an answer the language contradicts itself. Part I 2.3 says a nullable
never nests, and ?. flattens for that reason, but a map read did nest. Below
it was an Option<&Option<T>>. let found = m[2] followed by found == null
said false for a key that holds null. m[k] ?? null had no lowering that
gave back one Kind?, and handing the read to a ref Kind? parameter did not
compile.
| Language / tool | Its answer | What it costs there | Source |
|---|---|---|---|
| Kotlin | Map<K, V?>.get(k) is a V?: absent and stored null are one answer; containsKey tells them apart; m[k] = null stores |
nothing measurable; the distinction is one extra call where a program wants it | from memory, not checked against a version |
| TypeScript, Dart | Map.get is V \| undefined, [] is V?: flat, has / containsKey for the difference |
as Kotlin | from memory, not checked |
| Java | get answers null for both, containsKey for the difference; ConcurrentHashMap refuses null values, because “get gave null, then containsKey” is two steps another thread can come between |
a whole class of map refuses a value type | from memory (Doug Lea’s reasoning for the refusal), not checked |
| Rust, Scala, Haskell | nested: Option<&Option<V>>, Option[Option[V]], Maybe (Maybe v); and_then(Option::as_ref), flatten, join to make one |
the program writes the flattening; the distinction is always there | Rust’s HashMap::get signature |
| Swift | [K: V?]’s subscript is a V??; dict[k] = nil removes the key, and storing nil needs .some(nil) or updateValue(nil, forKey:) |
a known trap: the write and the read do not mean the same nil |
from memory, not checked |
| Go, C# | one step answers both: v, ok := m[k], TryGetValue(k, out v) |
a second result at every read | language specification, Dictionary<TKey,TValue> |
The split is the nullable’s kind. Where it is a wrapper type (Option,
Maybe, Swift’s optional) the read nests, because nothing in the type system
stops it. Where it is a property of the type (Kotlin, TypeScript, Dart, Java’s
null) it cannot nest, and the read is flat. Part I 2.3 puts this language in
the second family: T? never nests, and (A, B)? is not written.
What does not transfer. Java’s reason for refusing null values is
concurrency: two steps on a shared map. Here a map other threads see is
behind Locked or SharedMut, and a read and a contains_key inside one door
are one step. Swift’s trap is the write, and it is a trap only where = nil
means “remove”.
T?let mut m: collections::BTreeMap[i64, Kind?] = collections::BTreeMap()
m[2] = null // stores: the key is there
let k = m[2] // `Kind?`, `null`
let gone = m[9] // `Kind?`, `null`
if m.contains_key(2) { … } // the difference, where a program wants it
T? as a map’s value typelet mut m: collections::BTreeMap[i64, Kind] = collections::BTreeMap()
let mut empty: collections::BTreeSet[i64] = collections::BTreeSet()
let k = m[2] // `Kind??`, or a read that says which it is
| D1 the special case pays, not the common one | D2 the compiler works, not the developer | D3 no cost for the common case | D4 consistent with itself | If it is wrong | |
|---|---|---|---|---|---|
| A | the program that needs the difference writes one contains_key; every other read is as for any map |
the compiler flattens the read; nothing to write | the read stays a view, an Option<&T>: no copy, one and_then |
the same rule as ?. (Part I 2.3); a map of T? reads as a map of T |
narrowing to B later refuses programs that compile; widening to C adds a spelling - both are additive changes to a rare shape |
| B | every program with an absent value pays a second container | the developer keeps two containers in step | none at run time | consistent with (A, B)?, but refuses a type everything else accepts |
lifting the refusal later is additive |
| C | every read of such a map pays the distinction, wanted or not | the developer reads two levels, or learns a second spelling | none | a nullable that nests in one place only - what Part I 2.3 rules out | a third answer the rest of the language does not have |
Option A. It is what Part I 2.3 already says about nullables, applied to the one read that did not follow it. B refuses a type the rest of the language accepts, for a concurrency reason this language’s doors remove. C makes the common read pay for the rare question, and it is the nesting 2.3 rules out.
T? is one T?.m[k] and m.get(k) on a map of T? are a T?. A key that is absent and a
key that holds null both answer null. m.contains_key(k) is how a program
tells them apart.
m[k] = null stores null; removing a key is remove.A write through the brackets inserts (ADR-080 D2), and null is a value like
any other. After m[k] = null the key is there, contains_key says so and
len counts it. The write and the read mean the same null, which is the
trap Swift’s = nil falls into.
Option<&T> below.A map read is a view of what the map keeps (ADR-114 D4), and flattening does
not change that: the lowering is index::flat, and_then(Option::as_ref)
over the read. Nothing is copied. It is the shape a ref T? parameter has, so
the read is handed to one as it is.
T? of its own is wanted, a map’s read is copied out if the value copies, and refused otherwise.This holds for every map, not only one of T?. A read is an Option<&T>
below. An annotated let, an assignment, a return, a field or an argument
that keeps a T? of its own copies it out where T copies, as a view of a
number is the number (ADR-233 D4). Where T does not copy, the program is
refused with the copy to write, m[k]?.clone(): this language never copies
behind the program’s back.
T? reads exactly as a map of T does: ??, ?., == null,
and handing the read to a ref T? parameter.null” writes contains_key. Inside a
door over a shared map that is one step with the read.T? of its own - let k: Kind? = m[1], kept.push(m[1])
into a Vec[Kind?] - compiles where the value copies, from a map of T as
from a map of T?. Before, it reached rustc as mismatched types. Where
the value does not copy it is refused (NK1102) with ?.clone() as the way
out.v, ok and C#’s
TryGetValue do. No program has asked for one; contains_key beside the
read is the answer until one does.