ZetCode

Dart Null Safety

last modified October 18, 2026

In this article we explore null safety in Dart, a language feature that eliminates an entire class of bugs by making the compiler track which values can and cannot be null.

Null Safety Overview

Null safety means that a variable cannot hold null unless its type explicitly allows it. Dart implements this as sound null safety: the type system guarantees that a non-nullable expression never evaluates to null at runtime. The compiler rejects the code that would violate this guarantee, so many null dereference crashes never reach production.

Dart 3 requires null safety; the legacy mode without it is no longer supported. Two things change compared to older Dart code. First, variables are non-nullable by default. Second, you opt in to nullability by adding a question mark to the type. This flips the mental model: instead of asking "could this be null?", you ask "should this be allowed to be null?".

Soundness does not mean that all null-related failures disappear. A failed ! assertion, an uninitialized late field, or an invalid conversion from dynamic can still throw at runtime. These checks preserve the guarantee rather than allowing null into a non-nullable value.

Construct Meaning Example
String Non-nullable, must always hold a value String name = 'Alice';
String? Nullable, may be null String? nick;
?. Conditional member access user?.name
?? Default value when null name ?? 'unnamed'
??= Assign only when null count ??= 0
! Assert non-null at runtime value!
late Defer initialization checks or initialize lazily late String host;

Non-nullable by Default

The default type is non-nullable. Assigning null to a plain String is a compile-time error, not a runtime failure. To permit null, append ? to the type name.

non_nullable.dart
void main() {
  String name = 'Alice';
  // name = null; // compile-time error
  print(name.length); // 5

  String? nickname;
  print(nickname); // null
  // print(nickname.length); // compile-time error: nickname is null

  nickname = 'Al';
  print(nickname.length); // 2: promoted to String after assignment
}

The variable name is a non-nullable String, so the compiler refuses the assignment of null. The variable nickname is a nullable String?; it starts as null and can be read safely, but its length cannot be accessed at that point. Assigning 'Al' lets flow analysis promote the local variable to String, so the following nickname.length is valid even though its declared type remains String?.

$ dart run non_nullable.dart
5
null
2

Null-aware Operators

Dart provides a family of null-aware operators that let you work with nullable values without explicit if checks. The conditional access operator ?. short-circuits to null when its receiver is null. The ?? operator supplies a fallback value, and ??= assigns only when the target is currently null.

null_aware.dart
class Address {
  final String city;
  Address(this.city);
}

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

class Counter {
  int? value;

  void ensure() {
    value ??= 10;
    print(value); // 10

    value ??= 99;
    print(value); // 10
  }
}

void main() {
  var u1 = User('Alice', Address('Paris'));
  var u2 = User('Bob');

  print(u1.address?.city); // Paris
  print(u2.address?.city); // null
  print(u2.address?.city ?? 'Unknown'); // Unknown

  Counter().ensure();
}

For u1 the nested address is present, so u1.address?.city reads 'Paris'. For u2 the whole chain short-circuits: because address is null, u2.address?.city evaluates to null without throwing. Combining it with ?? yields the fallback string. In Counter.ensure the first value ??= 10 assigns because value is null; the second assignment is skipped because value now holds 10.

$ dart run null_aware.dart
Paris
null
Unknown
10
10

The Null Assertion Operator

Sometimes you know more than the compiler: a value is nullable by type but guaranteed non-null at a particular point. The null assertion operator ! tells the analyzer to treat the value as non-null. If the runtime value is actually null, the program throws a TypeError. Use it sparingly and prefer a real check when one is practical.

null_assertion.dart
void main() {
  Map<String, int> scores = {'alice': 90};

  int? alice = scores['alice'];
  print(alice! + 5); // 95

  int? bob = scores['bob'];
  try {
    print(bob! + 5);
  } on TypeError {
    print('failed: null assertion');
  }

  if (bob != null) {
    print(bob + 5);
  } else {
    print('no score for bob');
  }
}

The lookup for 'alice' returns 90, so alice! is safe. The lookup for 'bob' returns null; forcing it with bob! throws a TypeError, which the try block catches. The final if (bob != null) shows the safer alternative: after the check the compiler promotes bob to int inside the branch.

$ dart run null_assertion.dart
95
failed: null assertion
no score for bob

The late Keyword

The late keyword handles a common dilemma: a field or variable is non-nullable, but its value is not available at declaration time. Marking it late defers initialization to a later point while keeping the type non-nullable. Reading an uninitialized late field throws a LateInitializationError; a definitely unassigned local variable can also be rejected by the analyzer. late can be used with nullable types too; it does not change a variable's nullability.

late_keyword.dart
class Config {
  late String host;
  late int port;

  void load() {
    host = 'localhost';
    port = 8080;
  }

  @override
  String toString() => '$host:$port';
}

void main() {
  var cfg = Config()..load();
  print(cfg);

  late String greeting;
  greeting = 'Hello';
  print(greeting);

  // late final computed once
  late final int nowMilliseconds = DateTime.now().millisecondsSinceEpoch;
  print(nowMilliseconds > 0);
}

The fields host and port are declared without an initializer but are still non-nullable thanks to late. They are set in load before toString reads them. A late final variable is particularly useful for lazy one-time initialization: the expression runs only on first access and its value is cached.

$ dart run late_keyword.dart
localhost:8080
Hello
true

Type Promotion and Flow Analysis

The Dart analyzer performs flow analysis to track the possible types of a variable at every point in the program. When a null check or type test rules out null, the variable is promoted to its non-nullable type for the remainder of that branch. This lets you write null checks naturally instead of using ! everywhere.

type_promotion.dart
int length(String? s) {
  if (s == null) return 0;
  return s.length; // promoted to String by flow analysis
}

String? upper(String? s) => s?.toUpperCase();

void main() {
  print(length('Dart'));
  print(length(null));

  List<String?> names = ['Alice', null, 'Bob'];
  print(names.whereType<String>().toList());

  print(upper(null) ?? 'EMPTY');

  Object value = 'hello';
  if (value is String && value.isNotEmpty) {
    print(value.length); // promoted to String
  }
}

Inside length the guard if (s == null) return 0; means that any code after it sees s as a non-null String. Inside upper, the conditional access s?.toUpperCase() handles a null argument without an explicit check. A whereType<String>() call returns an Iterable<String> that excludes null entries; toList() then creates a List<String>. Promotion also works after an is test: once value is String holds, the variable is treated as a String, so value.length and value.isNotEmpty are both valid.

Promotion depends on the analyzer proving that the value cannot change in a way that invalidates the check. Local variables and parameters commonly promote; public fields and arbitrary getters do not. Eligible private final fields can also promote starting with Dart 3.2.

$ dart run type_promotion.dart
4
0
[Alice, Bob]
EMPTY
5

Null Safety in Collections

Null safety extends into collection literals and lookups. The null-aware spread operator ...? inserts elements only when the source collection is non-null, and the ?[] operator indexes a nullable map safely.

collections.dart
List<int> merge(List<int>? maybe) => [...?maybe, 3];

int? lookup(Map<String, int>? data, String key) => data?[key];

void main() {
  print(merge(null)); // [3]
  print(merge([1, 2])); // [1, 2, 3]

  Map<String, int>? m;
  var merged = {...?m, 'x': 1};
  print(merged); // {x: 1}

  print(lookup({'a': 1}, 'a')); // 1
  print(lookup({'a': 1}, 'z')); // null
  print(lookup(null, 'a')); // null
}

The merge function uses the null-aware spread: called with null it produces just [3], and called with [1, 2] it appends 3 to those elements. The same ...? syntax works for map literals. The lookup helper indexes a nullable map with data?[key], returning null without indexing when the map itself is null. Separately, the ordinary map [] operator returns null for a missing key even when the map's value type is non-nullable. That is why lookup returns int?.

$ dart run collections.dart
[3]
[1, 2, 3]
{x: 1}
1
null
null

Required Named Parameters

Null safety works closely with named parameters. A named parameter without required is optional; if it has no default value, its type must allow null. A non-nullable optional named parameter needs a non-null default. The required keyword means the argument must be supplied, independently of whether its type is nullable: required String? email still requires an argument but allows the caller to pass null.

required_parameters.dart
class Person {
  final String name;
  final String? email;

  Person({required this.name, this.email});

  @override
  String toString() => '$name <${email ?? 'no email'}>';
}

void main() {
  var p = Person(name: 'Alice');
  print(p);

  var p2 = Person(name: 'Bob', email: 'bob@example.com');
  print(p2);
}

The name parameter is required, so the constructor cannot be called without it. The email parameter is typed String? and has no required modifier, so it is optional; when it is omitted, the field holds null. The toString method uses ?? to render a readable placeholder for the missing value.

$ dart run required_parameters.dart
Alice <no email>
Bob <bob@example.com>

Source

Dart: Sound null safety
Dart: Understanding null safety
Dart Language: Operators
Dart Language: Variables

Null safety makes the possibility of null explicit in Dart's type system. By defaulting to non-nullable types, providing null-aware operators, and performing flow-based type promotion, the language turns a common source of runtime crashes into compile-time errors. Features such as late and required support deferred initialization and mandatory named arguments without weakening the declared types.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Dart tutorials.