Nullability, Optionality & the Billion-Dollar Mistake

Why a null that inhabits every type defeats the type system, what Option/Maybe buys and costs, how Kotlin, C#, Rust and Java each retrofitted or avoided it, and the traps that survive — platform types, typed nils, and SQL's three-valued logic.

Tony Hoare introduced the null reference in ALGOL W in 1965 and called it his billion-dollar mistake at QCon in 2009 — he had built the first comprehensive type system for references, aiming for compiler-checked safety, and put null in anyway "simply because it was so easy to implement". The technical problem is precise and worth stating that way: null is a value that inhabits every reference type, so String does not mean "a string" but "a string or nothing", and the type system stops carrying the one piece of information the caller most needs.

The fix is to move nullability into the type #

rust
fn find(id: u64) -> Option<User>   // the signature says it can be absent
match find(7) {
    Some(user) => greet(user),
    None       => prompt_login(),   // the compiler will not let you forget this arm
}

Option<T> is an ordinary sum type. Nothing is special about it except that the compiler forces the absent case to be handled before the value can be used. That is the whole mechanism, and everything below is a variation on how completely a given language achieves it.

How the mainstream languages got there #

Language Mechanism Enforcement Runtime representation
Rust, Haskell, OCaml, Swift Option/Maybe/Optional in the type system from the start compile error erased for references (niche optimization)
Kotlin String vs String? compile error inside Kotlin plain reference + generated checks at Java boundaries
C# 8+ nullable reference types warning, opt-in per project fully erased — attributes only
TypeScript strictNullChecks compile error erased
Java Optional<T> as a library type none a real heap object
Go no mechanism; nil plus a comma-ok idiom none —

The two retrofits are worth understanding precisely, because both leak.

Kotlin's platform types. A value coming from Java has unknown nullability. Kotlin does not guess: it gives it a platform type (String!) and lets you treat it as either, moving the check to the point of use. Annotate the Java side with @Nullable/@NonNull and Kotlin honours it. Without annotations, a Java null flowing into a Kotlin non-null parameter throws at the boundary rather than corrupting the interior — which is the right trade, but it means "Kotlin is null-safe" is only true of Kotlin-to-Kotlin calls.

C#'s nullable reference types are diagnostics, not guarantees. They are erased at run time, they are warnings by default, and any of the following puts a real null into a variable the compiler believes is non-null: a ! suppression, deserialization, reflection, a struct's default value, a partially-constructed object, or a library compiled without the feature. It is a genuinely useful analysis that catches a large class of bugs — it is not a proof, and code at a trust boundary must still check.

SQL is a third system, and it is three-valued #

SQL NULL means "unknown", and comparisons against it yield unknown rather than true or false.

sql
SELECT * FROM users WHERE email = NULL;        -- always zero rows; use IS NULL
SELECT * FROM users WHERE id NOT IN (1, 2, NULL); -- always zero rows
COUNT(col)  -- skips NULLs;  COUNT(*) does not

The NOT IN case is the one that silently returns an empty result set in production: id NOT IN (1,2,NULL) expands to id<>1 AND id<>2 AND id<>NULL, and the last conjunct is unknown, so the whole predicate can never be true. Use NOT EXISTS, or make the column NOT NULL. An OUTER JOIN also manufactures NULLs that were never in any table, so a WHERE on the outer side quietly turns it back into an inner join.

Where to put the check #

The productive framing is that null-handling belongs at the boundary, not sprinkled through the interior:

typescript
// boundary — the one place absence is a possibility
const raw = await db.findUser(id);
if (!raw) return notFound();
const user: User = parseUser(raw);   // interior types are total

// interior — no null checks anywhere, because the type cannot hold one
function greet(user: User) { ... }

Sources

  1. talkNull References: The Billion Dollar Mistake (Tony Hoare, QCon London 2009)
  2. docsKotlin docs — Null safety and platform types
  3. docsC# docs — Nullable reference types
  4. docsRust std::option — Option and the null pointer optimization
  5. bookEffective Java, 3rd Edition

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close