Null Safety — ?, !, ??, ?., late, and Flow Analysis
By the end of this lesson you will know exactly what null is, why it was called "the billion-dollar mistake", how Dart 3's sound null safety catches null-related crashes at compile time instead of at 3am in production, and how to correctly use every null operator (?., ??, ??=, !, ...?, null-aware collection elements), late, required, and the analyzer's flow-based "promotion" of nullable types.
1. What null is, and the billion-dollar mistake
null is a computer's way of saying "this labelled slot exists, but right now it holds nothing." It is not the number zero, not an empty piece of text '', and not false — it is the total absence of a value.In 1965, Tony Hoare added null references to a language design because it was "so easy to implement". In 2009 he publicly apologised, calling it his "billion-dollar mistake" — because in most languages of the time (Java, C, C++, JavaScript, pre-2.12 Dart), any variable that looked like it held an object could secretly hold null instead, and the compiler never warned you. The crash only happens later, at runtime, the moment your code tries to actually use the missing value — for example calling .length on a String slot that is secretly empty.
The same locker in Dart: null is not 0, not '', not false; and the old-language crash, reproduced with dynamic (which switches the compile-time checks off):
String never told you whether an empty locker was allowed. The bug is invisible until the exact code path that touches the empty locker actually runs — often in production, on a rare input nobody tested.null = "this slot exists but is empty right now." The billion-dollar mistake was letting EVERY variable secretly allow that empty state with no warning from the compiler.2. Sound null safety: the type hierarchy
Dart 3 has sound null safety, always on (there is no "opt out" any more). Every type is non-nullable by default: String can NEVER hold null — the compiler guarantees it, and can rely on that guarantee when generating code (see the Expert-tier "soundness" question; exactly which checks get removed is a compiler implementation detail). To allow the empty state, you write a question mark after the type: String? means "a String, or null". This one keystroke turns off the guarantee for that specific variable and turns ON a new rule: the analyzer will not let you use it as a plain String until you've proven it isn't null.
Dart's type hierarchy, top to bottom: Object? is the absolute top — every single value in Dart, including null itself, is an Object?. Below it splits into Object (every real, non-null value) and Null (the type that has exactly one value: null itself). Every concrete class (String, int, your own classes) sits under Object. At the very bottom sits Never — a special type with NO values at all, used for expressions that can never successfully produce a result (like a function that always throws). Because Never has no values, it's compatible with every other type — see the Expert-tier "Never subtype" question.
The hierarchy as runnable checks (is Object is true for every non-null value; List<Never> fits where List<int> is expected):
Input size → what's feasible: the ? in a type is a compile-time label only — it adds no per-element memory or time, so String? over n = 106 items costs the same as String.
void f(String? s) { print(s.length); } is flatly rejected — "The property 'length' can't be unconditionally accessed because the receiver can be 'null'."? to opt a type INTO allowing null. Object? ⊐ Object / Null ⊐ ... ⊐ Never (bottom, no values, fits everywhere).3. Safe navigation: ?. and short-circuiting
?. is like a security guard at a locked door: "IF this locker isn't empty, go ahead and open the next door; if it IS empty, stop right here and hand back an empty result — don't even try the door." Nothing beyond the first empty locker is ever touched.a?.b means: if a is null, the whole expression evaluates to null immediately, without even attempting to read .b. If a is not null, it behaves exactly like a.b. This chains: a?.b?.c stops at the FIRST null it meets, and none of the getters after that point ever run — not even to check if they'd throw.
Its siblings: ?[] (null-aware index) and ?.. (null-aware cascade — the whole cascade is skipped when the receiver is null):
Input size → what's feasible: every null-aware operator is O(1) (one comparison against null); 108 of them take about 1 s, so use them freely in loops over n ≤ 107 items.
a?.b.c — only ONE ?. — is perfectly fine when b can't be null: if a is null, the whole rest of the chain (.b.c) is skipped and the result is null (this is called null-shorting). The trap is the other case: if b itself can return null (its type is B?), then .c needs its own ?. — write a?.b?.c — or the analyzer rejects the line. Rule of thumb: put a ?. exactly after every link that can be null.4. ?? and ??= — filling in defaults
?? is a vending machine with a backup coin slot: "try my main coin (the left side); if that slot is empty (null), automatically use this backup coin (the right side) instead." ??= is a locker attendant who only stocks a spare item into your locker IF it currently has nothing in it — if you already have something, they leave it alone.a ?? b evaluates a; if a is not null, that's the result and b is never evaluated at all. If a IS null, the result is b. a ??= b is shorthand for "if a is currently null, set a = b; otherwise, change nothing" — it only ever assigns when the left side is empty.
Input size → what's feasible: ?? and ??= are one null test, O(1); the right side is only evaluated when needed, so an expensive fallback costs nothing on the non-null path (even for 107 calls).
x ?? fallback reads a value with a backup. x ??= fallback WRITES a backup only when empty — it's a no-op the moment x already holds something.5. ! — the null assertion operator
! is you personally signing a waiver that says "I PROMISE this locker is not empty — don't bother checking, gym staff, I already know." If you're right, everything proceeds normally. If you're wrong, and the locker really was empty, you get an immediate, loud, unrecoverable error the instant you make that claim.expr! tells the analyzer "trust me, this is not null" and converts a nullable type to its non-nullable form AT COMPILE TIME. At run time Dart still performs one tiny null check there: if you were right it passes and the value flows on; but if the value actually IS null, Dart throws a TypeError immediately with the message "Null check operator used on a null value" — a hard crash, exactly the kind of crash sound null safety is designed to prevent everywhere else.
Input size → what's feasible: ! is one null test, O(1) — speed is never the issue; correctness is: one wrong promise out of 106 calls still crashes the app.
! is OK: right after you've already checked (if (map.containsKey(k)) print(map[k]!)), or when a framework/library guarantees non-null in a context the analyzer can't see (rare). When it isn't: sprinkling ! everywhere just to silence analyzer errors without checking. That's re-inventing the billion-dollar mistake by hand — you've simply moved the crash from "any line" (old languages) to "this one line, still possibly at 3am in production".6. Null-aware spreads and collection elements
Building lists from possibly-null pieces has its own null-aware syntax. ...?maybeList — the null-aware spread — inserts every element of maybeList into the surrounding list literal, or inserts NOTHING at all if maybeList is null (instead of crashing). Newer still (Dart 3.8+, verified against the installed SDK 3.11 for this lesson): a single null-aware element, ?expr, inside a list/set/map literal includes expr only if it's non-null, and is silently omitted if it's null — no need to build a whole spread for just one optional value.
Input size → what's feasible: a null-aware spread copies the list it spreads, O(k) for k elements; building a result from 103 small pieces is instant, while spreading a 106-element list inside a loop of 106 iterations (1012 copies) is not — use addAll there.
if (maybe != null) maybe inside the literal, or the clumsy ...?(maybe == null ? null : [maybe]) (both give [1, 4] for a null maybe in a scratch run). The ?expr element form is pure convenience sugar over that same idea.7. Flow analysis & type promotion
Dart's analyzer performs flow analysis: it walks your code path by path and tracks, at every single line, what it currently knows about each variable's nullability. The moment you write if (x != null) { ... }, inside that { ... } block the analyzer promotes x's static type from T? to T — you can use it exactly like a non-nullable value, with no ! needed. The same promotion happens with an is check (if (o is int) { ... o is usable as int here ... }), and with an early return pattern: if (x == null) return; // from here on, x is promoted to non-null for the rest of the function.
Every promotion source from the card below, as code — != null, early return, is, && / || short-circuit, and a non-null assignment:
Input size → what's feasible: promotion is a compile-time analysis, so it costs nothing at run time regardless of input size.
if (x != null), if (x == null) return/throw/continue/break, if (x is SomeType), and (Dart 3.0+) if (field case final v?) pattern matching on a field. Once promoted, the variable behaves as its non-nullable type for the rest of that flow branch — until it's reassigned to something the analyzer can't re-prove.8. Why fields don't promote (and the fixes)
null) on every single read, so "I checked a moment ago" proves nothing about what the NEXT read returns.This is why if (obj.field != null) { obj.field.length } does not compile for a public instance field or getter — a subclass overriding field's getter could return null on the second read even though the first read didn't. The three fixes: (1) copy the field into a local variable first (final f = obj.field; if (f != null) f.length; — locals DO promote), (2) use a pattern: if (obj.field case final v?) { v.length; }, or (3) since Dart 3.2, a private final field (starting with _) with no custom getter override risk in the same library CAN be promoted directly, because the compiler can prove nothing outside the library can intercept it.
Input size → what's feasible: a null check plus a local copy is O(1), so every fix here is free at any input size (n = 106 objects); pick by readability, not speed.
9. late and required
late is a locker you're renting starting today, but you PROMISE to put something in it before anyone tries to open it — the gym doesn't check right now, it trusts you and only checks the moment someone actually opens the door. required is a form field marked with a red asterisk: the form (function call) is REJECTED before you even submit it if that field is left blank.required applies to named parameters: void greet({required String name}) forces every caller to pass name — leaving it out is a COMPILE error, not a runtime surprise. late lets you declare a non-nullable field or variable without an initial value, on the promise that it will be set before it's first read. If you break that promise — read it before assigning — Dart throws a LateInitializationError at that exact read, with a message like "LateInitializationError: Field 'name' has not been initialized." Two cousins: late final may be assigned exactly ONCE (a second assignment throws the same kind of error), and late final String lazy = _load(); runs its initializer only on the FIRST read — a lazily computed value. Both are in the Dart panel below.
Input size → what's feasible: a late read adds one initialised-check (O(1)); a lazy late final x = expensive(); pays the cost once, on first read — good when only some of n = 105 objects ever need x.
late vs nullable: making a field T? means EVERY reader must handle the possibility of null forever. Making it late T means you're asserting it will always be set before use, so readers get a clean, non-nullable T — but you take on the responsibility of actually initializing it in time, or you get a LateInitializationError crash. Use late for fields set once during setup (dependency injection, initState), and nullable for values that can genuinely, permanently be absent.10. Null in collections: List<int?> vs List<int>?
List<int?> is a shelf that definitely exists, made of individual boxes, and SOME of the individual boxes on it might be empty. List<int>? is the opposite: the whole shelf itself might not exist at all — but if it DOES exist, every box on it is guaranteed to hold something.These two types answer completely different questions, and mixing them up is a very common bug. List<int?>: the list reference is never null, .length always works, but list[i] can be null. List<int>?: you must null-check the LIST ITSELF before touching .length or indexing at all, but once past that check every element is a guaranteed int.
Input size → what's feasible: nonNulls / whereType are lazy O(n) filters: n = 106 elements ≈ 106 steps (fast); calling removeAt per null instead would shift elements, up to n²/2 = 5·1011 moves.
Cleaning nulls out of a List<int?>: xs.whereType<int>() filters to only the elements that are an int (dropping null), returning an Iterable<int>. xs.nonNulls does the same specific job — drop every null — and reads more clearly for that exact purpose. (A related, very commonly used helper, firstWhereOrNull, safely returns the first matching element or null instead of throwing — it comes from the external package:collection, not core Dart, so it's mentioned here for awareness only and isn't exercised in this lesson's verification file.)
List<int?> → check each ELEMENT. List<int>? → check the LIST ITSELF, once, up front.11. APIs that hand you null on purpose
Real Dart APIs use null as a deliberate, honest "nothing here" signal instead of throwing exceptions for expected, everyday absence: int.tryParse('abc') returns null (not a crash) when the text isn't a valid integer — contrast with int.parse('abc'), which throws a FormatException. A Map<K, V>'s operator [] has return type V?: map[missingKey] returns null rather than throwing, because "key not present" is a completely normal, expected outcome, not an error. stdin.readLineSync() (reading a line typed by a user) returns String?, because the input stream can legitimately end (e.g. Ctrl-D) with no more lines to give you.
Input size → what's feasible: tryParse reads the text once, O(L) for L characters (L ≤ 106 is fine); Map[] is O(1) on average, so 106 lookups ≈ 106 steps.
??, ?., or an explicit if, not with an unguarded !.Quiz
Interview questions
Cheat sheet
| Need | Use | Notes |
|---|---|---|
| Allow null for a type | T? | non-nullable by default otherwise |
| Stop a chain safely on null | a?.b?.c | short-circuits at the first null; rest never runs |
| Fallback value when null | a ?? b | b only evaluated if a is null |
| Fill in a default once | a ??= b | no-op if a already non-null |
| "I promise it's not null" | a! | throws TypeError at runtime if wrong |
| Spread a possibly-null list | [...?maybeList] | inserts nothing if null |
| Include one value only if non-null | [?maybe] | Dart 3.8+ |
| Narrow a nullable var after a check | if (x != null) { ... } | flow-analysis promotion; also is checks & early return |
| Promote a public field | copy to local, or if (f case final v?) | fields don't promote directly (except private final, 3.2+) |
| Set later, read as non-null | late T x; | throws LateInitializationError if read before set |
| Force a named argument | {required T x} | compile error if omitted, not runtime |
| Drop nulls from a list | xs.nonNulls / xs.whereType<T>() | both work; nonNulls reads clearer |
| Parse text safely | int.tryParse(s) | returns null instead of throwing |