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.
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.
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.
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.
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.
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.
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.
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
List all Dart tutorials.