Skip to content

02 · Null Safety Advanced

Level 1 covered the basics: ? marks a type nullable, ?? provides a fallback, and the compiler forces you to handle the null case before using a value. This module goes deeper into the tools and traps that show up once you're writing real, non-trivial Dart: late, promotion rules, the ! operator, and the migration gotchas that trip people up when null safety meets existing code.

late — promise now, initialize later

late tells the compiler "trust me, this will be assigned before anything reads it" — letting you declare a non-nullable field without providing a value immediately. It's essential for fields set up in a separate initialization step (dependency injection, initState-style setup) where a constructor-time value isn't available.

class Repository {
  // `late` promises "this will be set before it's read" -- and defers
  // initialization until first access, without forcing a nullable type.
  late final Config config;

  void configure(Config c) {
    config = c;
  }
}

class Config {
  String? _cachedName;

  String get name {
    // late-init-by-hand pattern: compute once, cache, reuse.
    return _cachedName ??= _expensiveLookup();
  }

  String _expensiveLookup() {
    print('doing expensive lookup...');
    return 'production';
  }
}

void main() {
  final repo = Repository();
  repo.configure(Config());
  print(repo.config.name);
  // doing expensive lookup...
  // production

  final repo2 = Repository();
  print(repo2.config.name); // never configured
}

Forgetting to call configure() before reading config doesn't fail at compile time — it's a runtime crash:

Unhandled exception:
LateInitializationError: Field 'config' has not been initialized.

late trades a compile-time guarantee for a runtime one. Use it when you're confident about initialization order (e.g. Flutter's initState), not as a generic escape hatch to silence the analyzer.

Null-aware operators, all together

class Address {
  String? city;
  Address(this.city);
}

class Person {
  String name;
  Address? address;
  Person(this.name, [this.address]);
}

String? findCityUpper(Person p) {
  // Null-aware chaining: short-circuits to null the instant any link is null.
  return p.address?.city?.toUpperCase();
}

void main() {
  final withCity = Person('Ada', Address('London'));
  final withoutAddress = Person('Grace');

  print(findCityUpper(withCity));       // LONDON
  print(findCityUpper(withoutAddress)); // null

  // Null-coalescing assignment: only assigns if currently null.
  String? nickname;
  nickname ??= 'Unknown';
  print(nickname); // Unknown

  String? maybeName;
  print(maybeName ?? 'Anonymous'); // Anonymous
}
Operator Meaning
?. Call/access only if the receiver isn't null; otherwise the whole expression is null
?? "Or else" — use the right side if the left side is null
??= Assign only if the variable is currently null
! Assert non-null; throws at runtime if wrong

The ! (bang) operator — a promise, not a fix

! tells the compiler to treat a nullable value as non-null right now. It's sometimes unavoidable, but it's also the single most common way null-safety bugs sneak back in — because it turns a compile-time category of bug back into a runtime one.

int riskyLength(String? maybe) {
  return maybe!.length;
}

void main() {
  riskyLength(null);
}
// Unhandled exception:
// Null check operator used on a null value

Treat ! as a note-to-self that says "I haven't proven this to the compiler yet" — every ! in a codebase is worth a second look during review.

Promotion — and where it silently stops working

After a null check, Dart usually "promotes" a nullable variable to its non-nullable type for the rest of that scope, so you don't need ! afterward. But promotion has real limits, and hitting one is a classic "why won't this compile" moment for people new to the language.

class Cache {
  String? value; // mutable, non-final field

  void printLength() {
    // Promotion does NOT apply to a mutable field, even right after a null
    // check -- the analyzer can't prove nothing else (a getter, another
    // method, a callback) mutates `value` between the check and the use.
    if (value != null) {
      // print(value.length); // Error: value is still String? here
      print(value!.length); // needs the bang, or...
    }
  }

  void printLengthFixed() {
    // ...the idiomatic fix: copy to a local variable. Locals CAN be
    // promoted, because the analyzer fully controls their scope.
    final local = value;
    if (local != null) {
      print(local.length); // promoted -- no bang needed
    }
  }
}

void main() {
  final cache = Cache()..value = 'cached data';
  cache.printLength();      // 11
  cache.printLengthFixed(); // 11
}

Promotion works for: local variables, and final fields declared in the same class. Promotion does not work for: mutable (non-final) fields, getters, and fields from another library/class — because in each of those cases something else could theoretically change the value between your check and your use. The fix is always the same: copy the nullable value into a local variable first, then check and use the local.

Migration gotchas

Two things trip people up most often when adapting older, pre-null-safety patterns or APIs to a null-safe codebase:

  • A default parameter value doesn't make a type non-nullable. void greet({String name = 'friend'})name is String, not String?, inside the function, but callers can still only omit it, not pass null explicitly, unless you write String? and handle it yourself.
  • JSON and other dynamic data are not automatically null-checked. json['age'] as int compiles fine but throws a TypeError at runtime if the key is missing — casting past dynamic re-opens the door null safety otherwise keeps shut. Use json['age'] as int? and handle null explicitly, or validate before casting. Module 5 covers this in depth.

How It Actually Works

Type promotion is implemented by Dart's flow analysis pass, which walks the control-flow graph of your function and tracks, at every program point, the most specific type it can prove a variable holds. A check like if (x != null) narrows x's known type from T? to T for the guarded branch — but this proof is invalidated the instant the analyzer can't guarantee the variable's value hasn't changed since the check. That's precisely why promotion "silently stops working" for fields (a getter could be overridden to return something different on each call, so the compiler can't trust two reads of this.field to agree) and for local variables captured by a closure that could run between the check and the use (the closure might reassign it). Promotion only holds for effectively-final locals and parameters where the flow graph can prove no intervening assignment or reentrant call could invalidate it.

late combines two runtime mechanisms: deferred definite-assignment checking, and (when given an initializer) memoization. The compiler generates a hidden sentinel/flag alongside the field — first access checks the flag, runs the initializer exactly once if unset, caches the result, and returns it forever after; direct assignment before that first read simply sets the value and flips the flag without running the initializer at all. This is genuinely different from a plain field, which is written during the constructor's execution (or fails to compile if not assigned by the end of it).

The ! operator's runtime check is a single conditional branch inserted by the compiler — cheap, but real: it is the sole place in a null-safe program where a null safety violation surfaces as an exception rather than a compile-time rejection, because you've asked the compiler to trust information it cannot itself verify (often information from a nullable API boundary, JSON, or FFI).

Exercise

Write a class Profile with a mutable nullable field String? bio. Add a method String bioPreview() that returns the first 20 characters of bio followed by '...' if it's longer than 20 characters, or 'No bio yet' if bio is null or empty — using the local-variable-promotion pattern from this module (no !). Then write a top-level function int? parseAge(Map<String, dynamic> json) that safely extracts an 'age' key as an int?, returning null if the key is missing or not an integer, without letting a bad cast throw.