Null Safety in Dart — The Billion-Dollar Mistake, Fixed
The most common runtime crash in programming history, and how Dart eliminates it at compile time. A deep dive into nullable types, null-aware operators, flow analysis, and the late keyword.
The billion-dollar mistake
In 1965, Tony Hoare invented the null reference. Decades later, he called it his "billion-dollar mistake" — because null reference errors have caused more crashes, more bugs, and more wasted debugging hours than perhaps any other single language feature.
The problem is simple. In most languages, any variable can secretly hold null. We write code assuming a value exists, and then — at runtime — it turns out it does not.
// In a language without null safety
String name = fetchUserName(); // might return null
print(name.length); // CRASH at runtime
The crash comes with no warning. The compiler happily compiled the code. The bug only appears when a user hits the exact path that returns null.
Dart 2.12 (released March 2021) introduced sound null safety. The idea is simple but powerful: make nullability part of the type. If a variable can be null, the type says so. If it cannot, the compiler enforces it.
The result is that null reference errors — the most common crash in programming history — become compile-time errors instead of runtime surprises.
Nullable vs non-nullable types
In Dart, types are non-nullable by default. A plain String cannot be null. Ever.
String name = 'Ankit'; // fine
name = null; // compile error — String cannot be null
To allow null, we add a ? to the type. This creates a nullable type.
String? name = 'Ankit'; // fine
name = null; // also fine — String? allows null
The ? is not decoration. It changes the type entirely. String and String? are different types in Dart's type system.
The diagram shows how Dart's type hierarchy works.
Object is the root of all non-nullable types. Null is its own separate type. A nullable type like String? is actually a union: it can hold a String or Null.
This means we cannot use a
String? where a String is expected — the compiler blocks it.
void greet(String name) {
print('Hello, $name');
}
String? maybeName = getUserName();
greet(maybeName); // compile error — String? is not String
The fix is to handle the null case first. Dart gives us several tools to do that.
Null-aware operators
Dart provides four operators specifically designed for working with nullable values. They make null handling concise without sacrificing safety.
The null-aware access operator: ?.
Use this when the object itself might be null. If it is, the whole expression evaluates to null instead of crashing.
String? name = null;
print(name?.length); // null (not a crash)
print(name?.toUpperCase()); // null
String? name2 = 'Ankit';
print(name2?.length); // 5
The null-coalescing operator: ??
Provides a default value when the left side is null.
String? name = null;
String displayName = name ?? 'Guest';
print(displayName); // 'Guest'
String? name2 = 'Ankit';
String displayName2 = name2 ?? 'Guest';
print(displayName2); // 'Ankit'
The null-aware assignment operator: ??=
Assigns a value only if the variable is currently null.
String? name;
name ??= 'Default'; // name is now 'Default'
name ??= 'Other'; // no change — name is already non-null
print(name); // 'Default'
The null-aware index operator: ?[]
Access a list or map element when the collection itself might be null.
List<int>? numbers = null;
print(numbers?[0]); // null (not a crash)
List<int>? numbers2 = [1, 2, 3];
print(numbers2?[0]); // 1
These operators chain naturally.
user?.address?.city?.toUpperCase() safely navigates a potentially null chain — if any link is null, the whole expression is null.
The bang operator (!)
Sometimes we know a value is not null, but the compiler does not. The bang operator ! tells Dart: "Trust me, this is not null."
String? name = fetchName(); // might be null
// We've checked elsewhere or have external knowledge
String definitelyName = name!; // assert non-null
print(definitelyName.length); // safe to use
But here is the catch. If we are wrong — if the value actually is null — the program crashes at runtime. We have just opted out of null safety for that expression.
String? name = null;
String definitelyName = name!; // CRASH — TypeError at runtime
When is
! appropriate?
• When we have already checked for null in a way the compiler cannot see (e.g., an
assert or a check in a different function).
• In tests, where a crash is acceptable and explicit.
• In initialisation code where we know the value is set before use.
When to avoid
!:
• When we are not 100% certain the value is non-null.
• When a safer alternative exists (use
??, ?., or a proper null check instead).
Think of
! as a contract with the compiler: "I promise this is not null. If I'm wrong, let it crash." Use it sparingly.
Late variables
Sometimes we cannot initialise a variable when we declare it, but we know it will be set before we use it. The late keyword tells Dart: "I promise to initialise this later."
late String name;
void init() {
name = 'Ankit'; // initialised here
}
void greet() {
print('Hello, $name'); // used here — after init()
}
Without late, Dart would complain that name is not initialised. With late, the check moves to runtime.
Lazy initialisation.
late also enables lazy evaluation. If we provide an initialiser, it runs only when the variable is first accessed.
late String expensiveValue = computeExpensiveValue();
void main() {
// expensiveValue has NOT been computed yet
print('Program started');
// Now it computes
print(expensiveValue); // computeExpensiveValue() runs here
print(expensiveValue); // cached — does not recompute
}
This is useful for fields that are expensive to compute and might not always be needed.
The danger of
late. If we read a late variable before it has been initialised, we get a runtime error.
late String name;
void main() {
print(name); // CRASH — LateInitializationError
}
When to use late:
• Instance variables initialised in a constructor body or
initState().
• Lazy fields that should compute only when accessed.
• Variables set by framework callbacks (Flutter's
didChangeDependencies, etc.).
When to avoid
late:
• When the variable might genuinely not be set — use nullable instead.
• When we can initialise directly or in the initialiser list.
Required named parameters
Named parameters in Dart are optional by default. But sometimes a parameter is both named (for clarity) and required (for correctness). The required keyword combines both.
void createUser({
required String name,
required String email,
int age = 0,
}) {
print('$name ($email), age $age');
}
createUser(name: 'Ankit', email: 'a@b.com'); // works
createUser(name: 'Ankit'); // compile error — email required
createUser(email: 'a@b.com'); // compile error — name required
Before null safety, named parameters had to be nullable or have defaults. Now we can have non-nullable named parameters by marking them required.
This is crucial for Flutter widgets. Most widget constructors use named parameters extensively, and
required makes the API self-documenting.
class UserCard extends StatelessWidget {
final String name;
final String email;
final int? age;
const UserCard({
super.key,
required this.name, // must provide
required this.email, // must provide
this.age, // optional — nullable
});
@override
Widget build(BuildContext context) { ... }
}
The required keyword shifts a potential runtime error ("forgot to pass name") into a compile-time error. The IDE shows the error before we even run the code.
Flow analysis and type promotion
Dart's compiler is smart. When we check for null, the compiler remembers — and automatically promotes the type from nullable to non-nullable within the safe scope.
void greet(String? name) {
if (name == null) {
print('Hello, Guest');
return;
}
// Here, Dart KNOWS name is not null
// Type is promoted from String? to String
print('Hello, $name');
print(name.length); // no error — name is now String
}
We did not use !. We did not cast. Dart's flow analysis tracked that after the if (name == null) return, name must be non-null in the remaining code.
Promotion works with many patterns:
// Pattern 1: if-return (shown above)
if (name == null) return;
// Pattern 2: if-else
if (name != null) {
print(name.length); // promoted inside the block
}
// Pattern 3: logical AND
if (name != null && name.length > 5) {
// promoted after the && check
}
// Pattern 4: throw
if (name == null) throw ArgumentError('name required');
print(name.length); // promoted after the throw
When promotion does NOT work:
Instance fields and top-level variables cannot be promoted. Another piece of code might change them between the check and the use. For those, use a local variable.
class User {
String? name;
void greet() {
if (name != null) {
// name is NOT promoted here
// because another thread could set name = null
print(name!.length); // must use ! or assign to local
}
// Better pattern:
final localName = name;
if (localName != null) {
print(localName.length); // localName IS promoted
}
}
}
Flow analysis is what makes null safety feel lightweight. We write natural null checks, and the compiler does the bookkeeping.
Test your understanding
7 questions
Seven questions covering null safety fundamentals, operators, late variables, and flow analysis.