Sound Null Safety in Practice

FlutterDart
Share on LinkedIn

Null safety migration is mostly done—Dart 3 requires it—but teams still sprinkle ! on API responses and wonder why production crashes. Sound null safety is only sound when boundaries treat external data as untrusted and the type system reflects optional vs required domain concepts. The mechanics are simple: ?, !, late, ??. The discipline is architectural.

Nullable vs non-nullable

String alwaysThere;      // cannot be null
String? maybeThere;      // can be null

void greet(String? name) {
  print('Hello, ${name ?? 'guest'}');
}

void strict(String name) {
  // caller must not pass null — compile error if String? provided
  print('Hello, $name');
}

Flow analysis promotion

void printLength(String? text) {
  if (text == null) return;
  print(text.length); // text promoted to String
}

void checkFlag(bool? enabled) {
  if (enabled == true) {
    // enabled promoted to bool inside block
  }
}

Promotion fails if variable reassigned or captured in closure before use—local final helps:

final value = maybeNull;
if (value != null) {
  use(value); // promoted
}

late initialization

class ProfileController {
  late final UserRepository repo;

  ProfileController(UserRepository repository) {
    repo = repository;
  }
}

// Flutter pattern
class _MyState extends State<MyWidget> {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this, duration: kDuration);
  }
}

late final assigns once; late without final allows reassignment.

Avoiding bang operator

// Bad — crashes if API lies
final name = json['name']! as String;

// Better
final name = json['name'] as String? ?? 'Unknown';

// Best — typed parser
factory User.fromJson(Map<String, dynamic> json) {
  final name = json['name'];
  if (name is! String) throw FormatException('name required');
  return User(name: name, bio: json['bio'] as String?);
}

Reserve ! for invariants proven by logic analyzer cannot see—document why safe.

Collection and generic variance

List<String>? nullableList;
List<String?> stringsWithNulls;
// List<String> ≠ List<String?> — map keys often String?, values Object?

Required named parameters

class Order {
  Order({required this.id, required this.total, this.note});

  final String id;
  final double total;
  final String? note;
}

Constructor enforces non-null for required business fields.

Migration leftovers

// ignore: avoid_dynamic_calls — audit these
dynamic legacy = fetchLegacy();
final s = legacy as String; // can throw

Replace dynamic boundaries with Object? and type checks.

// @dart=2.9 files should be zero in Dart 3 projects.

JSON with json_serializable

@JsonSerializable()
class Account {
  const Account({required this.email, this.phone});

  final String email;
  final String? phone;

  factory Account.fromJson(Map<String, dynamic> json) => _$AccountFromJson(json);
}

Run build_runner; nullable fields match optional JSON keys.

Platform channels

final result = await channel.invokeMethod<String>('getToken');
if (result == null) {
  throw StateError('Platform returned null token');
}
return result; // promoted String

Platform returns are always nullable from type system's view.

Testing null cases

test('handles missing bio', () {
  final user = User.fromJson({'name': 'Ada'});
  expect(user.bio, isNull);
});

Test null branches explicitly—coverage tools flag untested ? paths.

Null-aware operators in depth

Dart provides several operators beyond ? and !:

// Null-aware access
user?.profile?.avatarUrl  // short-circuits on null

// Null-aware assignment
cache ??= computeExpensive();  // assign only if null

// Null-aware spread
final combined = [...?maybeList, ...requiredList];

// Null assertion cascade — avoid
user!.profile!.avatar!;  // chain of crashes waiting to happen

?. is preferred over if (x != null) x.field for property chains. ??= is useful for lazy initialization without late.

Null safety in async code

Async gaps break flow analysis promotion:

Future<void> processUser(String? id) async {
  if (id == null) return;
  await fetchProfile(id);  // ERROR: id might be null after await
  print(id.length);        // promotion lost across async gap
}

Fix with local final:

Future<void> processUser(String? id) async {
  final userId = id;
  if (userId == null) return;
  await fetchProfile(userId);
  print(userId.length);  // promoted — local final not reassigned
}

This pattern applies everywhere async work sits between null check and use — network calls, file I/O, delays.

late pitfalls in Flutter

Common LateInitializationError sources:

// Bug: accessed before initState completes
class _MyState extends State<MyWidget> {
  late final controller = AnimationController(vsync: this);  // vsync not available at field init

  // Fix: initialize in initState
  late final AnimationController controller;
  @override
  void initState() {
    super.initState();
    controller = AnimationController(vsync: this, duration: kDuration);
  }
}

Never use late for fields that depend on BuildContext or TickerProvider — initialize in initState or didChangeDependencies.

Generic nullability

Generics and nullability interact confusingly:

T? nullableGeneric<T>(T? value) => value;  // T might be non-nullable type param
List<String?> listWithNullElements;         // list cannot be null, elements can
List<String>? nullableList;                 // list can be null, elements cannot be null (if String non-nullable)

When designing APIs, be explicit: Future<User?> returns nullable user; Future<User> guarantees user or throws.

Migration audit checklist

For codebases still carrying null-safety debt:

Failure modes

Production checklist

Resources

Frequently asked questions

What is sound null safety in Dart?

Sound null safety guarantees non-nullable types cannot hold null at runtime—the compiler inserts checks and flow analysis proves safety. Unlike nullable annotations in some languages, Dart rejects code that could assign null to String without String?. Runtime null on non-nullable type throws before unsound behavior spreads.

When should I use late instead of nullable?

Use late when a non-nullable field is initialized after construction but before first read—State initState, dependency injection setup. Use nullable when absence is meaningful business state—optional profile photo, missing API field. late throws LateInitializationError if read before assign; nullable forces explicit handling.

How do I handle null from JSON APIs?

Parse at boundary with explicit defaults or nullable types—User.fromJson returns nullable fields as Type? and required fields throw on missing. Avoid ! bang operator on API data; use ??, ??=, or early return. json_serializable includeIfNull and required annotations document contract.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →