Null Safety in Dart
This is Part 5 of the Dart Fundamentals series. If you're arriving from a search, the short version of Part 4 is: Dart is statically typed and infers types for you. Now we add the feature that makes Dart genuinely safe — sound null safety.
This is the one I'd put at the top of "why Dart is nice to work in." Let's see why.
The billion-dollar mistake
In 1965, Tony Hoare added null to a language and later called it his "billion-dollar mistake" — because for decades, null has been the single most common source of crashes. You've seen it in other forms:
- Java:
NullPointerException - JavaScript:
undefined is not a function - Python:
AttributeError: 'NoneType' object has no attribute…
They all share one root cause: a variable that was supposed to hold a value held nothing, and the program tried to use it anyway.
Dart's answer is sound null safety: by default, a variable cannot be null at all. If a value might be missing, you have to say so explicitly — and once you do, the compiler forces you to handle the "missing" case before you can use it. The result: this entire category of crash is caught at compile time, before your app ships.
Non-nullable by default
Here's the foundation. A plain type can never hold null:
int age = 30;
age = null; // ❌ compile error — int can never be null
String name; // declared, not assigned
print(name); // ❌ compile error — used before it has a value
That second one is huge. Dart won't even let you read a variable until it's definitely been assigned. So "I forgot to set it" bugs vanish too.
This means that anywhere you see a normal type — int, String, User — you can trust it holds a real value. No defensive if (x != null) checks scattered everywhere. That trust is the whole point.
Making a type nullable with ?
When a value genuinely might be absent — a middle name, an API field that's sometimes missing, a search result that found nothing — you opt in with a ? after the type:
int? age = 30;
age = null; // ✅ allowed now — the ? means "or null"
Read int? as "an int, or null." That single character changes everything: it's a different type from int, and Dart now treats it with suspicion.
String? middleName; // defaults to null, and that's fine
The catch — and this is the good part — Dart won't let you use a nullable value directly, because it might be null:
int? maybeCount = getCount();
print(maybeCount + 1); // ❌ compile error — it might be null!
You have to prove it's not null first. Dart gives you several tools for that.
Proving non-null: type promotion
The simplest tool is a plain if check. Just like is promoted types in Part 4, a null check promotes a nullable to its non-null form inside the block:
int? maybeCount = getCount();
if (maybeCount != null) {
// inside here, Dart KNOWS it's not null
print(maybeCount + 1); // ✅ treated as a plain int
}
Inside the if, maybeCount is effectively an int. Clean, readable, no special operators. This is the idiomatic default — reach for it first.
The null-aware operators
Constantly writing if (x != null) gets verbose, so Dart gives you a family of compact null-aware operators. These are the ones you'll use every single day.
?? — default value ("if null, use this")
If the left side is null, fall back to the right side:
String? name = getName();
String display = name ?? 'Guest';
// if name is null → 'Guest', otherwise → name
??= — assign only if currently null
String? nickname;
nickname ??= 'Anonymous'; // sets it only because it was null
nickname ??= 'Other'; // does nothing — already has a value
?. — safe call ("only if not null")
The most beloved one. Call a member only if the receiver isn't null; otherwise the whole expression evaluates to null instead of crashing:
String? name = getName();
int? length = name?.length;
// if name is null → length is null
// if name is 'Asha' → length is 4
Compare that to the crash you'd get in other languages calling .length on null. Here it just short-circuits to null.
Chaining them together
These compose beautifully. A very common real-world pattern:
// "the user's city, or 'Unknown' if anything along the way is null"
String city = user?.address?.city ?? 'Unknown';
If user is null, or address is null, the chain stops and ?? supplies the fallback. One readable line replaces a pyramid of null checks.
The ! bang operator — "I promise it's not null"
Sometimes you know a nullable value isn't null right here, even though the compiler can't prove it. The null assertion operator ! lets you force a nullable into its non-null type:
int? maybeCount = 5;
int definite = maybeCount!; // "trust me, it's not null"
print(definite + 1); // ✅
But beware — this is a loaded gun. If you're wrong, it throws at runtime:
int? maybeCount = null;
print(maybeCount! + 1); // 💥 runtime crash — "Null check operator used on a null value"
So ! is the null-safety equivalent of as from Part 4: it trades a compile-time guarantee for a runtime risk. Use it sparingly, and only when you're genuinely certain. Nine times out of ten, a ?? or an if check is the better choice and won't ever crash.
Code-smell alert: a codebase peppered with
!everywhere usually means someone is fighting null safety instead of modelling their data honestly. If you reach for!a lot, ask whether the type should have been nullable in the first place.
The late keyword
There's a real situation null safety makes awkward: a variable that is definitely non-null, but you can't assign it on the same line as the declaration. Maybe it's set up in an init method, or it's expensive to compute. You don't want it nullable (it's never really null), but Dart insists every non-nullable variable be initialized.
late is the escape hatch. It tells Dart: "this WILL be non-null — just not yet. Trust me, and check at runtime that I keep my promise."
class Profile {
late String username; // non-null, assigned later
void load(Map<String, dynamic> json) {
username = json['username'] as String; // set here
}
}
You get a normal non-nullable String to use everywhere — but if you read it before assigning, you get a clear runtime error instead of a silent null:
late String name;
print(name); // 💥 "LateInitializationError: Field 'name' has not been initialized."
late for lazy initialization
late has a second superpower. If you give it an initializer, that initializer doesn't run until the first time the variable is read — lazy evaluation, for free:
late String expensive = _doHeavyComputation();
// _doHeavyComputation() hasn't run yet...
print(expensive); // NOW it runs, once, and caches the result
print(expensive); // uses the cached value — no recomputation
If you never read expensive, the heavy work never happens. Great for costly setup you might not always need.
When to use late — and when not to
- ✅ A non-null field initialized in a constructor body,
initState, or a setup method. - ✅ An expensive computation you want to defer until first use.
- ❌ As a lazy way to dodge thinking about null. If a value can genuinely be absent, use
?and handle it — don't slaplateon it and hope.
A quick tour of where these show up together
Here's a small, realistic snippet pulling the pieces together:
class User {
final String name;
final String? email; // optional — might not be provided
late final int id; // assigned once, in the constructor body
User(this.name, this.email) {
id = _generateId();
}
String contactLine() {
// ?. and ?? : show email if present, else a fallback
final shown = email ?? 'no email on file';
return '$name <$shown>';
}
}
name is always there. email is honestly nullable, so we handle it with ??. id is non-null but computed after the field list, so it's late final. No ! needed anywhere — that's the sign of a well-modelled type.
Practice Challenges
Work them out before opening the solution.
Challenge 1 — Pick the right operator. Given String? input, write one line that prints the input, or '(empty)' if it's null.
Show solution
void show(String? input) {
print(input ?? '(empty)');
}
?? is exactly "use the left value, or this fallback if it's null."
Challenge 2 — Safe chaining. Given String? name, get its length as an int? without crashing when name is null.
Show solution
int? len(String? name) => name?.length;
// null in → null out; 'hi' in → 2 out
?. short-circuits to null instead of throwing.
Challenge 3 — Why does this fail? Explain the compile error, then fix it two different ways.
int? n = 10;
int doubled = n * 2; // ❌
Show solution
n is int? — it might be null — so Dart refuses the arithmetic. Two fixes:
// Option A: promote with an if-check (safest)
if (n != null) {
int doubled = n * 2;
}
// Option B: supply a default with ??
int doubled = (n ?? 0) * 2;
(You could also write n! * 2, but only if you're certain n isn't null — it crashes otherwise.)
Challenge 4 — late or ?. A Database field on a service is created once in a setup method and used everywhere afterward, never null in normal use. Which keyword fits, and why not the other?
Show solution
class Service {
late Database db; // non-null, assigned in setup()
void setup() {
db = Database.connect();
}
}
Use late, not Database?. The field is conceptually always present — making it nullable would force pointless db?. / db! everywhere. late keeps it a clean non-null Database, with a clear LateInitializationError if you ever forget to call setup().
Challenge 5 — Trace the bang. What does this print, or does it crash?
List<int?> nums = [1, null, 3];
int total = 0;
for (final n in nums) {
total += n!;
}
print(total);
Show solution
It crashes on the second element. n! asserts non-null, but the middle value is null, so you get "Null check operator used on a null value." Safer:
for (final n in nums) {
total += n ?? 0; // treat missing as 0 instead of crashing
}
print(total); // → 4
This is the classic lesson: ! is only safe when you've actually guaranteed non-null.
Check Yourself (Q&A)
Q1. What does "non-nullable by default" mean?
A plain type like int or String can never hold null. You must use int? / String? to allow null, which makes "missing value" visible in the type itself.
Q2. What's the difference between ?. and !?
?. is safe — it returns null instead of calling on a null receiver. ! is an assertion — it forces non-null and throws at runtime if the value really is null.
Q3. When does ?? evaluate its right-hand side?
Only when the left side is null. a ?? b gives a if non-null, otherwise b.
Q4. Does late make a variable nullable?
No — the variable stays non-nullable. late just defers the requirement to initialize it. Reading it before assignment throws a LateInitializationError.
Q5. Why is a codebase full of ! a warning sign?
It usually means the data is being forced non-null instead of modelled accurately. If something can truly be absent, it should be a ? type handled with ??/?./promotion — not asserted away with !.
Q6. What's "sound" about Dart's null safety?
"Sound" means the guarantee is airtight: if the type system says a value is non-null, it genuinely cannot be null at runtime (assuming you don't lie with !). The compiler can rely on it for optimizations, and you can rely on it for correctness.
Wrapping Up
- Dart is non-nullable by default — plain types can't be null, which eliminates a whole class of crashes.
- Add
?to opt into nullability (int?= "int or null"); Dart then forces you to handle the null case. - Handle it with an
ifcheck (promotion), or the null-aware operators:??(default),??=(assign if null),?.(safe call). !forces non-null but crashes if you're wrong — use rarely.latekeeps a variable non-null while deferring its initialization, and doubles as lazy, cache-on-first-read initialization.
Next, in Part 6, we get to the genuinely fun part: collections — List, Set, and Map, how they differ, and the spread/collection-if tricks that make Dart's collection literals a joy.