Ankit Ranjan
Back to Deep Dives

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.

May 24, 2026 7 topics 7 quiz questions
Share:
1

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.

The null reference crash — invisible until runtimeString name = ...Compiles fineNo warningsname.lengthCompiles fineStill no warningsCRASHRuntime errorUser sees it firstThe compiler saw nothing wrong. The bug hid until production.Null reference errors account for a huge percentage of app crashes.The fix: make null visible in the type system.

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.

2

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 type hierarchy — non-nullable and nullableObject?ObjectNullStringintListNon-nullable types (cannot hold null)Only holds nullString? = String | Null

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.

3

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
Null-aware operators at a glance?.null-aware accessname?.length??null-coalescingname ?? 'Guest'??=null-aware assignname ??= 'Default'?[]null-aware indexlist?[0]These operators short-circuit: if the left side is null, the right side never executes.They return null instead of crashing, letting us chain safely: user?.address?.city

These operators chain naturally. user?.address?.city?.toUpperCase() safely navigates a potentially null chain — if any link is null, the whole expression is null.

4

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
The bang operator: a sharp toolname!When you are RIGHT:Compiles and runs finename!When you are WRONG:Runtime crash (TypeError)Rule: avoid ! unless you have a genuinely good reason. Prefer null checks.

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.

5

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.

6

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.

7

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.

Flow analysis promotes types automaticallyString? namenullable — could be nullname == null?yesnoreturn earlyexit the functionString namepromoted to non-nullableAfter the null check, Dart knows name cannot be 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.

Search

Loading search...