Null safety
Sound null safety β what the compiler proves, the operators for working with nullable values, and why `!` should make you uncomfortable.
Dart's null safety is sound: if a variable's type is String, it cannot hold null, and the compiler relies on that rather than merely hoping for it.
Nullable and non-nullable
void main() {
String definitely = 'always a String';
String? maybe;
print(definitely.length);
print(maybe);
print(maybe?.length);
maybe = 'now assigned';
print(maybe.length); // flow analysis proved it is not null here
}void main() {
String definitely = 'text';
definitely = null; // error! A value of type 'Null' can't be assigned to a variable of type 'String'.
print(definitely);
}The operators
void main() {
String? name;
print(name ?? 'anonymous'); // ?? fallback when null
print(name?.toUpperCase()); // ?. call only when not null
print(name?.length ?? 0); // chain them
name ??= 'assigned by ??='; // assign only if currently null
print(name);
name ??= 'ignored'; // no longer null, so skipped
print(name);
final list = <String>['a', 'b'];
print(list.firstWhere((s) => s == 'z', orElse: () => 'not found'));
}| Operator | Means |
|---|---|
? | this type may be null |
?. | call only if not null, otherwise evaluate to null |
?? | use the right side if the left is null |
??= | assign only if currently null |
! | "I promise this is not null" β throws if it is |
?.. | null-aware cascade |
Flow analysis
The compiler tracks nullability through your control flow, so explicit checks promote the type for the rest of the block.
int lengthOf(String? text) {
if (text == null) {
return 0;
}
// text is String from here β no ! and no ? needed
return text.length;
}
String describe(String? text) {
if (text == null) return 'nothing';
if (text.isEmpty) return 'empty';
return 'a string of ${text.length} characters';
}
void main() {
print(lengthOf(null));
print(lengthOf('hello'));
print(describe(null));
print(describe(''));
print(describe('Dart'));
}void main() {
String? text = 'hello';
if (text != null) {
print(text.length); // promoted: no ? needed
}
// Early return is the idiomatic shape:
final result = text == null ? 0 : text.length;
print(result);
}class Message {
String? body;
int lengthWithLocal() {
final b = body; // copy to a local
if (b == null) return 0;
return b.length; // promoted
}
}
void main() {
final m = Message()..body = 'hello';
print(m.lengthWithLocal());
print(Message().lengthWithLocal());
}! β the assertion you should justify
void main() {
String? name = 'Ada';
print(name!.length); // works, because it is not null
final map = <String, int>{'a': 1};
print(map['a']!); // map lookup returns int?, and we know 'a' exists
}void main() {
String? name;
print(name!.length); // error! Null check operator used on a null value
}β οΈ Every ! is a promise the compiler cannot verify, and a crash if you are wrong. Before writing one, try: an if check, ?? with a default, ?. with a null-aware chain, or fixing the type so it was never nullable. A ! on data that came from outside your program β JSON, a query result, a form β is almost always a bug waiting for the right input.
Nullable collections
There are three different things here, and they are worth separating.
void main() {
List<String>? maybeList; // the list itself may be null
List<String?> listOfMaybe = ['a', null, 'c']; // the items may be null
List<String?>? both; // either
print(maybeList?.length);
print(listOfMaybe);
print(listOfMaybe.whereType<String>().toList()); // drops the nulls, and the type
print(both?.length ?? -1);
final lengths = listOfMaybe.map((s) => s?.length ?? 0).toList();
print(lengths);
}whereType<String>() is the clean way to go from List<String?> to List<String> β it filters and narrows the type in one step.
late and null safety
class Repository {
late final List<String> items; // non-nullable, assigned later
void initialise() {
items = ['first', 'second'];
}
}
void main() {
final repo = Repository();
repo.initialise();
print(repo.items);
// The alternative without `late`:
// List<String>? items;
// β¦which forces every reader to handle a null that never actually happens.
}The trade: late gives every reader a clean non-nullable type at the cost of a possible LateInitializationError; a nullable field gives compile-time safety at the cost of a check at every use. Choose late when the initialisation is genuinely guaranteed by a lifecycle you control.
required versus nullable
class User {
final String name; // required, never null
final String? nickname; // optional, may be null
final int age; // required, has a default at the call site
User({required this.name, this.nickname, this.age = 0});
@override
String toString() => '$name (${nickname ?? 'no nickname'}), age $age';
}
void main() {
print(User(name: 'Ada'));
print(User(name: 'Grace', nickname: 'Amazing Grace', age: 85));
}required is about the call site β the argument must be passed. ? is about the type β the value may be null. A parameter can be required and nullable (required String? x): you must pass something, and that something may be null.
Never
Never fail(String message) {
throw StateError(message);
}
int parsePort(String value) {
final n = int.tryParse(value);
if (n == null) fail('Not a number: $value');
return n; // the compiler knows fail() cannot return
}
void main() {
print(parsePort('8080'));
try {
parsePort('abc');
} on StateError catch (e) {
print('caught: ${e.message}');
}
}Never is the bottom type: a function returning it cannot complete normally. That is what lets parsePort return int rather than int? without any further check.
Reference: Sound null safety and Understanding null safety on dart.dev.