ADR-273: A Map of T? Values Reads One T?, and Writing null Stores It

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

1. Question

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.

2. How others do it

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

3. Options, weighed

Option A — flat, as Kotlin: the read is one 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

Option B — refuse T? as a map’s value type

let mut m: collections::BTreeMap[i64, Kind] = collections::BTreeMap()
let mut empty: collections::BTreeSet[i64] = collections::BTreeSet()

Option C — keep the two answers apart, with a spelling for each

let k = m[2]                  // `Kind??`, or a read that says which it is

Weighed

  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

4. Decision

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.

D1 — A read of a map whose values are 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.

D2 — 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.

D3 — The read is a view, an 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.

D4 — Kept where a 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.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.