Type Designimplementation

Nullability and Optional Types

Two ways to represent absence: a type that silently includes an extra value and a flow analysis to exclude it, or an ordinary sum type with no special status at all. They differ in what they cost you at the boundary, in the signature, and in bytes.

The question

Should absence be a nullable type or an Option, and what does each actually cost at runtime?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Either a type that implicitly contains one extra value — T also admits null — refined by flow analysis into a non-null form at proven program points; or Option<T> = None | Some(T), an ordinary sum with no privileges in the checker. The first is a property of the *checker*, applied to data that was already there. The second is a property of the *data*, and everything downstream follows from that difference.

What this phase may assume or do

A dereference of a nullable type is well-typed only at program points where flow analysis has eliminated null on every incoming path. The analysis may carry a non-null fact for a field from the check to the use only if nothing in between could have written it — which is why nullable locals narrow well and nullable fields barely narrow at all. For the sum-type form there is no flow condition to satisfy: the payload is unreachable without going through the constructor.

Key points

  • Two designs: keep null and add a flow analysis to exclude it, or remove null and represent absence as an ordinary sum type.
  • Flow analysis narrows locals well and fields badly, because narrowing a field would need an interprocedural aliasing proof.
  • The standard workaround — copy the field to a const local, check that — is not a trick; it removes the aliasing question entirely.
  • Option composes because it is data: it can be collected, mapped, nested and made generic over, none of which a nullable annotation supports.
  • Option<Option<T>> distinguishes two absences that T?? collapses into one.
  • Niche optimization makes Option<&T> exactly one pointer; Option<u32> is genuinely larger because a u32 has no spare bit pattern.
  • Neither design says anything about values arriving from JSON, a database driver or an unchecked module — that boundary needs a parse step in both.

Two representations of absence

Tony Hoare called the null reference his billion-dollar mistake, and that is the whole of what needs saying about the history; the interesting part is what the two available answers actually are.

The first answer keeps null and adds a checker. The type string no longer admits null; string? does; and a flow analysis works out where a nullable value has been checked, so that after if (s != null) you may use it. C# 8 nullable reference types, Kotlin, TypeScript under strictNullChecks and Swift's optionals all sit here in various forms. Nothing changes in the representation — a null is still a null pointer or an absent property — so existing data, existing APIs and existing serialized formats all keep working. The guarantee is about the code you compiled, not about the values that reach it.

The second answer removes null from the language and represents absence as data: Option<T> is a sum with two variants, exactly like any other sum. Rust, ML, Haskell and Scala do this. There is nothing in the checker about it: Option is defined in the standard library, matched like any other enum, and gets exhaustiveness from [[exhaustiveness-checking]] for free. The cost is that absence is now visible in the type and must be eliminated explicitly, everywhere, which is a real ergonomic burden that these languages spend a lot of syntax on reducing.

The trade is not "safety versus convenience". Both approaches catch the same class of mistake within the region they cover. They differ in how they behave at the edges — at a deserialization boundary, at an FFI boundary, at a mutable field, and at a partially migrated codebase — and that is where the choice is actually made.

Nullable-with-flow-analysis versus Option-as-dataimplementation
AxisNullable + flow analysis (C#, Kotlin, TS)Option as a sum (Rust, ML, Haskell)
Where absence livesIn the type annotation. The value is still a null.In the data. None is a constructed value.
How it is eliminatedA check the checker models, at a program point.A match, a combinator, or an explicit unwrap.
Composes with genericsAwkwardly: T? where T may already be nullable is a known sore spot.Naturally: Option<Option<T>> is a distinct, meaningful type.
Migration of existing codeIncremental. Unannotated code keeps compiling.All at once. There is no null to migrate from.
Behaviour at a deserialization edgeA null arrives where the type says it cannot; nothing notices.The value cannot be constructed without choosing a variant.
Runtime representationimplementationA null pointer or an absent property. Zero extra cost.A tag plus payload — often folded to zero cost, see below.
Signature noiseOne character.A wrapper type that propagates through every signature it touches.

Flow analysis, and exactly where it gives up

implementationThe precise point at which a field narrowing is discarded is TypeScript 5.x behaviour and differs from C# and Kotlin: C# tracks nullable state through more constructs but reports violations as warnings that a project may suppress wholesale, and Kotlin refuses to smart-cast an open or mutable property at all rather than narrowing and then discarding. Any code that depends on a subtle narrowing surviving a particular construct is depending on a compiler version.

The nullable-plus-analysis design lives or dies on how well the analysis tracks a non-null fact, and the pattern is the same in every implementation: locals narrow well, fields narrow badly. A local that has been checked and not reassigned is provably non-null at every later point in the block. A *field* that has been checked is provably non-null until any code the checker cannot see through runs — because that code might hold a reference to the object and write the field.

This is not conservatism for its own sake. Making it work would require proving that no reachable code writes the field, which is an interprocedural aliasing question — the subject of [[alias-analysis]] — and no frontend type checker is going to run one. So the checkers all take the sound and annoying option, and every engineer using them learns the same workaround: copy the field into a local, check the local, use the local.

The second place it gives up is at an initialization boundary. A non-nullable field must be assigned before use, and proving that across constructors, initializers and lazily-populated builders is a definite-assignment analysis with its own limits. C# reports it as a warning, Kotlin has lateinit as an explicit opt-out, and TypeScript has the definite-assignment assertion — three different admissions that the analysis cannot always tell.

And the third is the boundary this whole domain keeps returning to. A value from JSON, from a database driver, from reflection, or from a module compiled without the checks can be null regardless of its declared type. The analysis reasoned about the code; it never had a claim on the data.

The same check, narrowing and not narrowing
1interface Session { user: User | null }
2
3function greetBad(s: Session) {
4 if (s.user !== null) {
5 log() // opaque call: the checker must assume s.user could change
6 return s.user.name // error: Object is possibly "null"
7 }
8}
9
10function greetGood(s: Session) {
11 const user = s.user // copy to a local
12 if (user !== null) {
13 log() // a `const` cannot be reassigned by anyone
14 return user.name // fine
15 }
16}

The two functions are identical to a reader and different to the checker. The const is what carries the fact past the call, because no aliasing argument is needed to know it did not change.

Option as ordinary data, and why that composes

The argument for Option is not that it is safer at the point of use — both designs catch the unchecked dereference. It is that because Option is data rather than a checker feature, everything that works on data works on it. You can put one in a collection, return one from a function that also returns errors, map over it, and use exactly the same combinators you use for Result and for lists. A nullable annotation cannot do any of that: it is not a value, so it has no methods, and T? in a generic position is a persistent source of special cases in every language that has it.

The distinction between Option<Option<T>> and T?? is the sharpest illustration. A cache lookup that returns "no entry" versus "an entry whose value is absent" is two different absences, and the sum type distinguishes them because they are genuinely different values. The nullable annotation collapses them, because null is null and there is only one.

The ergonomic cost is real and every one of these languages pays it down with syntax: ? for propagation in Rust, do notation in Haskell, for comprehensions in Scala, and combinator libraries (map, and_then, unwrap_or) everywhere. Without those, code drowns in matching, and it is fair to say that Option in a language with no propagation sugar is a worse experience than a nullable type with good flow analysis.

The corresponding hazard is the escape hatch. Rust's .unwrap() and Scala's .get convert the absence into a panic at the point of use, which is better than a null dereference three frames later but is still a runtime failure. A codebase that reaches for unwrap reflexively has reproduced the null problem with a different spelling and a better stack trace.

Absence as data, and the sugar that makes it bearable
1fn lookup(cache: &Cache, k: &str) -> Option<Option<Value>> {
2 // no entry ──┘ └── entry present, value absent
3 cache.entries.get(k).cloned()
4}
5
6fn first_name(u: &User) -> Option<&str> {
7 let full = u.name.as_ref()?; // `?` returns None early
8 full.split(' ').next() // Option<&str>
9}
10
11// It is data, so it collects and composes like data:
12let names: Option<Vec<&str>> = users.iter().map(first_name).collect();
13let display = first_name(&u).unwrap_or("anonymous");

The collect line turns a sequence of options into an option of a sequence, short-circuiting on the first None. That is only possible because Option is an ordinary type the standard library can be generic over — a nullable annotation has no such handle.

What each costs at runtime

The reflex objection to Option is that it adds a word for the tag. Often it does not, because of *niche optimization*: if the payload type has bit patterns it can never occupy, the compiler uses one of them as the None tag and the whole Option occupies exactly as much space as the payload. A reference can never be null in Rust, so Option<&T> is one pointer, with the null pattern meaning None — literally the same representation the nullable design uses, arrived at by the optimizer rather than by the language.

This is why Option<Box<T>>, Option<&T> and Option<NonZeroU32> are all the same size as their payloads, and why Option<u32> is not: every 32-bit pattern is a valid u32, so there is no niche and the tag needs its own space plus alignment padding. The rule is worth internalising because it changes the answer for large collections: a Vec<Option<u64>> is twice the size of a Vec<u64>, and a Vec<Option<&T>> is not bigger at all.

The comparison that goes the other way is Java's Optional<T>, which is an ordinary heap object wrapping a reference. Using it as a field type means a second allocation and a second pointer chase per access, which is why the JDK's own guidance is that Optional is intended as a return type rather than a field or parameter type. An escape analysis may remove the allocation when the Optional does not escape the method — but that is a JIT decision, dependent on inlining having happened first, and not something to design around.

None of this changes the design conclusion much, and that is the point: the runtime costs are small, sometimes zero, and occasionally material for large arrays. Choose on the semantics — composition, boundary behaviour, migration path — and then check the layout if you are about to allocate a hundred million of them.

Sizes, where the niche exists and where it does notimplementation
TypeSize vs payloadWhy
Option<&T> (Rust)implementationIdentical — one pointerA reference is never null, so the null pattern is free for None.
Option<Box<T>> (Rust)implementationIdentical — one pointerSame niche. This is why boxed recursive enums are not penalised.
Option<NonZeroU32> (Rust)implementationIdentical — four bytesZero is the niche, which is the entire purpose of the NonZero types.
Option<u32> (Rust)implementationLarger — eight bytesEvery bit pattern is a valid u32; the tag needs its own byte plus padding.
Option<bool> (Rust)implementationIdentical — one byteA bool occupies only two of 256 patterns, so a niche is available.
Optional<T> (Java)implementationA separate heap object plus a referenceIt is an ordinary class. Escape analysis may elide it; that is a JIT outcome, not a guarantee.
T | null (TypeScript)implementationIdenticalNothing is emitted at all; the union exists only in the checker.

How it works

The steps, in the order the compiler takes them.

  • For the nullable design, the checker splits each reference type into nullable and non-nullable forms and runs a forward flow analysis whose facts are per-variable nullness.
  • Comparison against null, an assertion, a call to a method declared to guarantee non-nullness, and assignment from a non-null source all contribute transfer functions that update those facts.
  • Facts about fields and array elements are invalidated at any call, closure invocation or assignment the analysis cannot prove irrelevant, because it does not run an aliasing analysis.
  • Definite-assignment analysis separately ensures a non-nullable field is written before it is read, with explicit escape hatches where it cannot tell.
  • For the Option design there is no analysis at all: Option is declared in the library, the payload is reachable only through a match or a combinator, and exhaustiveness comes from the ordinary sum-type machinery.
  • At layout time the compiler looks for a niche — a bit pattern the payload cannot occupy — and folds the discriminant into it, so that many Option types are the same size as their payloads.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A field is checked, a logging call happens, and the checker reports "possibly null" on the very next line; the engineer adds a non-null assertion, and eighteen months later a concurrent writer makes it null and the assertion becomes a production crash.
  • A response is parsed from JSON into a type whose fields are declared non-nullable; a missing field arrives as undefined, every type-level guarantee is intact, and the failure appears as a null dereference deep in rendering code with no indication of where the value entered.
  • A codebase migrating to nullable reference types suppresses the warnings project-wide to get a green build, and the annotations become documentation that nothing enforces.
  • A Rust codebase uses .unwrap() as the default elimination form; the panics are well-located but just as frequent as the null dereferences they replaced, and the stack traces all point at the unwrap rather than at whoever produced the None.
  • A Vec<Option<u64>> with a hundred million entries uses twice the expected memory and the profile shows cache misses rather than an allocation spike, because the cost is in the layout rather than in the count.
  • Optional is used as a field type in Java, adding an allocation and a pointer chase per access; the effect is invisible in microbenchmarks where escape analysis removes it and visible in production where inlining did not fire.

When it helps

  • Any API whose result may legitimately be absent, where making that visible in the signature moves the decision to the caller instead of hiding it in a nullable return.
  • Migrating an existing large codebase: the nullable-plus-analysis design exists precisely because it can be adopted file by file.
  • Distinguishing kinds of absence — no entry versus an entry with no value — where the sum type keeps them separate and null cannot.
  • Domains where absence is common and meaningful (parsing, configuration, caches), where the elimination step at each use is a feature rather than noise.

When it hurts

  • Deep in hot data structures, where an Option without a niche doubles the size of an array and the tag costs a branch per access.
  • At an FFI or serialization boundary, where the guarantee does not hold and code written as though it does will fail on real inputs.
  • In a language with no propagation sugar, where Option turns straight-line code into a match pyramid and people route around it with unwraps.
  • When applied to a value that is never actually absent. Wrapping something that always exists adds noise to every signature it passes through and teaches readers to ignore the wrapper.

What it costs

Every one of these is paid by something.

  • Nullable-with-flow-analysis buys incremental migration of an existing codebase and pays with a guarantee that stops at every boundary: unannotated modules, reflection, deserialization and FFI can all produce a null the type says is impossible, and nothing reports it.
  • Option-as-data buys composition, generic uniformity and the ability to distinguish nested absences, and pays in signature noise and in an explicit elimination at every use — a cost that has to be bought back with dedicated syntax or the design is rejected by its users.
  • Niche optimization buys zero-cost optionality for references and pays in compiler complexity and in unpredictability: two types that look equally cheap differ in size by a factor of two depending on whether the payload happens to have a spare bit pattern.
  • Flow analysis buys ergonomics — no explicit unwrap — and pays in surprise and in version-sensitivity: what narrows and what discards a narrowing is checker behaviour, not a language guarantee, so a compiler upgrade can change which of your code compiles.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Non-nullable by default with no flow analysis at all — the value simply cannot be absent, and absence is modelled by a separate type or a separate function. Simpler and stricter; it is what you get when a language has no null from the start.
  • A null object: a real instance with neutral behaviour, so callers never branch. Removes the check entirely at the cost of hiding the absence, which is the wrong trade whenever the caller genuinely needs to know.
  • Nullable annotations checked by an external tool (Java's @Nullable with an annotation processor, Python's Optional[T] with mypy) get most of the checking without a language change, at the cost of being advisory: the runtime does not enforce them and an unchecked caller can pass anything.
  • Total functions that return a default rather than an absence — getOrDefault — remove the case entirely where a sensible default exists, which is a genuinely better answer more often than either wrapper.

See it for yourself

The flag, dump or tool that shows you this directly.

  • rustc -Z print-type-sizes (nightly) prints the size of every type and names the niche it used, so the Option<&T> claim is directly verifiable rather than taken on trust.
  • std::mem::size_of::<Option<T>>() compared with size_of::<T>() is the two-line version of the same check and needs no nightly compiler.
  • tsc --strictNullChecks versus without: compiling a codebase both ways and diffing the error count is the fastest measure of how much the annotations are actually carrying.
  • For C#, <Nullable>enable</Nullable> with TreatWarningsAsErrors; without the second half the analysis is advisory and its findings accumulate unread.
  • For Java, -XX:+PrintInlining and a JFR allocation profile show whether Optional allocations are actually being elided in the paths you care about, which is the only way to know rather than assume.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Option is safer than nullable." Within the code both cover, they catch the same mistake. They differ at boundaries, in composition, and in what a partially migrated codebase looks like — which is where the decision actually lives.
  • "Option costs a word of memory." Often it costs nothing, because the compiler folds the tag into a bit pattern the payload cannot use. Sometimes it costs eight bytes. Check the type rather than assuming either.
  • "The checker said it is not null, so it cannot be null." The checker reasoned about the code it compiled. A value from JSON, from reflection or from an unannotated module obeys no such rule.
  • "! and .unwrap() are just noise you add to satisfy the compiler." They are assertions that a proof exists which the compiler could not find. If you cannot state the proof, the assertion is a scheduled failure.

Misconceptions

The claim, and what is actually true.

Languages without null cannot express absence.
They express it as data. The absence is a value you construct and match on, which is more expressive rather than less — it nests, it composes, and it can be told apart from a different absence.
strictNullChecks makes null-dereference errors impossible.
It rules out that class of error in the code it checked, which excludes anything reached through any, JSON, reflection, or a module compiled without it. The boundary is exactly where the parse step belongs.
Optional in Java and Option in Rust are the same thing.
One is a heap object holding a reference; the other is a compile-time construct that frequently occupies zero extra bytes. They agree on the API and differ on everything a profiler can see.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

A value that might not be there needs a representation. One approach keeps null and teaches the compiler to track where you have checked for it. The other removes null entirely and makes absence an ordinary value — None — that you have to unwrap before you can use what is inside. Both catch the mistake of using a value you have not checked; they differ in what happens at the edges of the checked region.

practical

In a nullable language, the single most useful habit is to copy a nullable field into a const/val local before checking it — that is what makes the narrowing survive the next function call, and it is why the same check works in one function and not another. Avoid the assertion operators (!, !!, .unwrap()) unless you can state the proof they stand for. And in both designs, validate at the boundary: a type annotation says nothing about a value that came from JSON, so parse it into the type once, at the edge, and let the rest of the program trust it.

advanced

The deep difference is which side of the compiler owns the concept. Nullable types are a checker feature applied to an existing representation, which is why they are adoptable incrementally and why they interact badly with generics — T? where T may itself be nullable has no good answer, and every language with the feature has a special case for it. Option is a library type with no privileges, which is why it composes with everything and why it costs an explicit elimination. The consequences run right down to layout: because Option is an ordinary enum, the ordinary enum layout optimizer applies, and Option<&T> ends up with exactly the representation the nullable design started with. The two approaches converge on the same bytes and disagree entirely about who is allowed to reason about them.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationNiche optimization is a rustc layout decision, not a language guarantee: Rust explicitly leaves enum layout unspecified without #[repr], and the only representation the language promises is that Option<&T>, Option<Box<T>> and the other pointer-like cases are ABI-compatible with the payload for FFI purposes. Other compilers for other languages may add a full word for the tag in every case.
implementationJava's Optional is a heap-allocated object. Escape analysis in HotSpot can scalar-replace it when it does not escape, but that depends on the allocation site having been inlined into a compiled method first, so the cost is present in interpreted and lightly-tiered code and absent in hot code. Designing on the assumption that it is free is designing for the profile you did not measure.
implementationEverything said about which constructs discard a narrowing describes TypeScript 5.x under strictNullChecks. C# reports nullability violations as warnings (suppressible per-project), and Kotlin declines to smart-cast mutable or open properties rather than narrowing them at all. Three different behaviours are marketed with the same word.
specThat a Rust reference is never null is a language rule, which is what makes the niche available. In C and C++ a pointer may be null, so no equivalent optimization is available for a raw pointer and std::optional<T*> genuinely costs an extra byte plus padding.

If you were asked this in an interview

  • Compare nullable reference types with an Option sum type. Name a situation where each is clearly the better choice.
  • Why does a null check on a field stop narrowing after a function call, and what is the standard fix?
  • Is Option<&T> bigger than &T in Rust? Explain your answer, and then say what Option<u32> costs and why.

Connections