Dart (3.13) Module 4 – Sound null safety
Type promotion, late and definite assignment
Predict when flow analysis promotes a nullable variable, fix cannot-be-promoted errors on fields, and use late and late final without a LateInitializationError.
What you will learn
- Predict when a nullable variable is promoted to a non-nullable type
- Fix cannot-be-promoted errors on fields and getters
- Use late and late final correctly and recognise LateInitializationError
- Choose a nullable type or late by whether a value can legitimately be missing
Before you start
What you’ll learn · Before you start
What you will learn
- Predict when a nullable variable is promoted to a non-nullable type
- Fix cannot-be-promoted errors on fields and getters
- Use late and late final correctly and recognise LateInitializationError
- Choose a nullable type or late by whether a value can legitimately be missing
Before you start
On this page
Null safety would be painful if every nullable value needed a ! or a fallback at each use. It is not, because flow
analysis promotes: after you check that a value is not null, the compiler treats it as non-null for the rest of the
scope. This lesson shows when promotion works, why it fails on fields, and the late keyword for a value that is set
before use but not where it is declared.
You will predict promotion from a null check and an is test, read the error when a field will not promote and apply
three fixes, watch a late final run its initializer once, and meet the LateInitializationError that a late value
throws when it is read too soon.
Flow analysis promotes a variable
promote.dart reads a nullable parameter after an early return, and an Object after an is test:
// Flow analysis promotes a local or a parameter to a non-null or more specific
// type after a check that rules out the other cases.
int lengthOrZero(String? s) {
if (s == null) return 0;
return s.length; // s is promoted to String here
}
String describe(Object o) {
if (o is int) {
return 'int ${o + 1}'; // o is promoted to int inside the if
}
return 'not an int';
}
void main() {
print(lengthOrZero('hello'));
print(lengthOrZero(null));
print(describe(41));
print(describe('x'));
} Output
5 0 int 42 not an int
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run promote.dart
It printed 5, 0, int 42 and not an int. In lengthOrZero, once if (s == null) return 0 has run, the only way
to reach s.length is with s not null, so flow analysis promotes s from String? to String and the call
compiles. In describe, o is int promotes o to int inside the if, which is why o + 1 is allowed. Step
through the first one:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- s may be null. s arrives with type String?, so reading s.length directly is a compile error; the value might be null.
- The guard removes null. if (s == null) return 0 leaves the function whenever s is null, so any code after it runs only when s is not null.
- Flow analysis promotes s. Because the null case has returned, flow analysis narrows s to String for the rest of the function. No ! is needed.
- The use compiles. s.length is now a call on a String, so lengthOrZero('hello') returned 5 and lengthOrZero(null) took the early return and gave 0.
flow analysis: The compiler's tracking of what must be true at each point in the code, used to promote types, check definite assignment and find unreachable code after an early return or a Never-returning call. promotes local variables and parameters after != null, is checks and
early returns. One thing undoes it: assigning to the variable between the check and the use. After s = maybeNull(),
the compiler can no longer be sure s is non-null, so the promotion ends and you check again.
Key idea
A null check or an is test promotes a local or parameter for the rest of the scope, so later uses need no !. An
assignment to that variable in between ends the promotion.
Checkpoint
Check: Flow analysis promotes a variable
Fields and getters do not promote
The same check fails on a field. field_promote.dart has a public nullable field and checks it before use:
// A public, non-final field cannot be promoted: another part of the program
// could change it between the check and the use, so this does not compile.
class Profile {
String? nickname;
String greet() {
if (nickname != null) {
return 'Hi ${nickname.toUpperCase()}';
}
return 'Hi there';
}
}
void main() => print(Profile().greet()); Output (exit status 254)
Printed as an error (standard error)
field_promote.dart:8:29: Error: Method 'toUpperCase' cannot be called on 'String?' because it is potentially null.
Try calling using ?. instead.
return 'Hi ${nickname.toUpperCase()}';
^^^^^^^^^^^
field_promote.dart:4:11: Context: 'nickname' refers to a public property so it couldn't be promoted.
See http://dart.dev/go/non-promo-public-field
String? nickname;
^
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run field_promote.dart
It did not compile: the error says nickname “refers to a public property so it couldn’t be promoted”, with status
254. The reason is real, not a limitation: another part of the program could change the field, or a subclass could
override it with a getter that returns a different value each time, so the compiler cannot trust that the value checked
is the value used. field_fix.dart shows three fixes:
// Three ways to use a nullable field safely. The field is private and final,
// which since Dart 3.2 can itself be promoted.
class Profile {
final String? _nickname;
Profile(this._nickname);
// Fix 1: copy the field into a local, which always promotes.
String greetLocal() {
final name = _nickname;
if (name != null) return 'Hi ${name.toUpperCase()}';
return 'Hi there';
}
// Fix 2: a private final field promotes directly since Dart 3.2.
String greetDirect() {
if (_nickname != null) return 'Hi ${_nickname.toUpperCase()}';
return 'Hi there';
}
// Fix 3: an if-case pattern binds the non-null value.
String greetCase() {
if (_nickname case final name?) return 'Hi ${name.toUpperCase()}';
return 'Hi there';
}
}
void main() {
print(Profile('asha').greetLocal());
print(Profile('ravi').greetDirect());
print(Profile(null).greetDirect());
print(Profile('meera').greetCase());
} Output
Hi ASHA Hi RAVI Hi there Hi MEERA
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run field_fix.dart
It printed Hi ASHA, Hi RAVI, Hi there and Hi MEERA. Copying the field into a local, as greetLocal does,
always works, because a local promotes. Making the field private and final lets it promote directly, as greetDirect
relies on: since Dart 3.2 a private final field can be promoted when nothing else in the library declares the same
name. And an if-case pattern, _nickname case final name?, binds the non-null value without touching the field again.
The version history is short:
- 3.0
Locals and parameters only baseline
Flow analysis promoted local variables and parameters after a null check or an is test, but fields and getters never promoted, because another library could change them.
- 3.2
Private final fields promote improvement
A private final field can be promoted when nothing else in the library declares the same name, since nothing outside can replace it with a getter that returns something different.
- 3.13
A soundness fix fix
Dart 3.13 made a small promotion change to close a rare unsoundness involving nested functions, so a few programs see promotion where they did not, or lose it where it was unsafe.
The timeline has 3 events, from 3.0 to 3.13.
A promotion change in 3.13
Dart 3.13 made a small fix to promotion to close a rare unsoundness involving nested functions. Most code is unaffected; a few programs gain or lose a promotion, which the changelog and the Dart 3.13 announcement describe.
Checkpoint
Check: Fields and getters do not promote
late moves the check to run time
Sometimes a non-nullable value cannot be set where it is declared, but you know it will be set before it is read. The
late keyword covers that: it moves the “is it initialised?” check from compile time to run time. A
late variable: A variable declared with late: a non-nullable value whose initialisation check moves to run time. late final allows one assignment, and a late initializer runs lazily on first read. with an initializer is also lazy. late.dart shows both:
// A late final with an initializer runs the initializer once, and only the
// first time the variable is read.
late final String config = _load();
String _load() {
print('loading config');
return 'ready';
}
void main() {
print('main starts');
print(config);
print(config);
} Output
main starts loading config ready ready
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run late.dart
It printed main starts, then loading config, then ready twice. The initializer _load() did not run when the
program started; it ran at the first read of config, which is why loading config appears after main starts. The
second read used the stored value, so _load ran only once. late final allows exactly one assignment, so it fits a
value computed lazily and then fixed. Without an initializer, late simply promises you will assign the variable
before reading it. Compare that with plain definite assignment: The compiler's check that a local variable has been given a value on every path through the code before it is read; a read that might find no value is a compile-time error. More in “Variables: var, final, const and type inference”, where
final int x; can be assigned once on every path before use, with no late needed and the check done at compile time.
Definite assignment is the cheaper of the two whenever the compiler can see the assignment: a local declared without a
value, then set in both branches of an if, needs no keyword at all, and a path that forgets to set it fails to build
rather than at run time.
Checkpoint
Check: late moves the check to run time
LateInitializationError
The promise late makes is checked at run time, so breaking it throws. late_error.dart reads a late field that was
never assigned:
// Reading a late field before it has been assigned throws at run time.
class Session {
late String token;
String describe() => 'token is $token';
}
void main() {
final s = Session();
print(s.describe());
} Output (exit status 255)
Printed as an error (standard error)
Unhandled exception: LateInitializationError: Field 'token' has not been initialized. #0 Session.token (late_error.dart) #1 Session.describe (late_error.dart:4:35) #2 main (late_error.dart:9:11) #3 _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:313:19) #4 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:192:12)
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run late_error.dart
It threw LateInitializationError: Field 'token' has not been initialized and ended with status 255. This is the price
of late: the compile-time guarantee is traded for a run-time one, so a code path that forgets to assign the field
crashes instead of failing to build. The message names the field, which is the thread to pull: look for the path that
reaches a read before the write, usually a constructor that returns early, an object used before its init call, or a
test that skipped its setup. That is why late suits values that really are always set before use, such as a field
wired up by dependency injection or assigned in a test’s setUp, and is a poor fit where a value might legitimately be
absent.
Checkpoint
Check: LateInitializationError
Prefer a nullable type when a value can be missing
When a value can legitimately be missing, a nullable type says so and the compiler keeps you honest; late hides the
possibility and turns a missing value into a crash. The two express different intents:
| Situation | Reach for | Why |
|---|---|---|
| The value may genuinely be absent | String? x |
the type documents it and forces each reader to handle null |
| The value is always set before use, just not at declaration | late String x |
avoids a nullable type and a ! on every use |
| The value is computed once, lazily, then fixed | late final x = compute() |
runs the initializer on first read and caches it |
| The value is set once on every path before use | final int x; |
definite assignment, checked at compile time |
Choosing a nullable type for a value that can be missing means a forgotten case is a compile error you fix, not a
LateInitializationError a user finds. Reserve late for the genuine “set before use” cases.
Checkpoint
Check: Nullable type versus late
Practise: fix a Session with late fields
Repair a class whose late fields crash for a logged-out user.
- Start from a
Sessionwith severallatefields such aslate String token;andlate User user;, where a logged-out session never sets them, so reading one throwsLateInitializationError. - For each field, decide: is it always present once the session exists, or can it be absent for a logged-out user? Make the always-present ones stay (or initialise them in the constructor) and the possibly-absent ones nullable.
- Update the readers: a nullable field needs promotion with a local copy or an if-case, not a
!. - Run it for a logged-in and a logged-out session and confirm neither throws, and that
dart analyzereports no promotion errors.
When both sessions behave, the self-check revisits each section:
Practice that adapts to you
Practise this lesson
5 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): When should you prefer a nullable type over a late variable?
Prefer a nullable type whenever the value can legitimately be missing. A nullable type, such as String? coupon,
records that absence in the type, so the compiler forces every reader to handle the null case and a forgotten one is a
compile error. late makes the opposite promise: the value is non-nullable and will always be set before it is read,
with the check moved to run time, so a path that forgets to set it throws a LateInitializationError for a user to
find rather than failing to build. Reserve late for values that genuinely are always set before use but cannot be
initialised at their declaration, such as a field wired up by dependency injection or assigned in a test’s setUp, or
for a late final lazy initializer that runs once on first read. If you are reaching for late only to avoid a
nullable type for something that can be absent, choose the nullable type instead.
Two bank questions apply promotion and late to everyday class and session design:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A class has a public field String? status, and a method checks if (status != null) then calls status.trim(). The compiler reports that status cannot be promoted. Explain why a local variable would promote but this field does not, and give the fixes you would choose between.
-
Mid to senior
A Session class declares late User user; and late String token; so the rest of the code can use them without null checks. Logged-out sessions never set them, and the app crashes with LateInitializationError when a guest opens a profile screen. Explain the trade-off the team made with late, and how you would redesign Session.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What promotes a nullable local variable to a non-null type?
Flow analysis: after != null, an is test or an early return, the compiler treats a local or parameter as the narrower type for the rest of the scope. An assignment to the variable in between ends the promotion.
-
Why does a null check not promote a public field?
A public or non-final field could change, or be overridden by a getter, between the check and the use. Copy it to a local, make it private and final (promotable since Dart 3.2), or use an if-case.
-
What does late final x = f(); do?
It runs f once, at the first read of x, and keeps the value. A late final with no initializer allows one assignment instead, and late moves the initialisation check from compile time to run time.
-
What happens when a late variable is read before it is assigned?
It throws a LateInitializationError, the run-time price of dropping the compile-time guarantee.
-
When should a value be nullable rather than late?
When it can legitimately be missing: String? coupon makes every reader handle null, so a forgotten case is a compile error. Keep late for values that are always set before they are read.
-
What changed about promotion in Dart 3.13?
A small fix that closes a rare unsoundness involving nested functions. Most code is unaffected; a few programs gain or lose a promotion.
Key takeaways
- Flow analysis promotes a local or parameter to a non-null or more specific type after
!= null, anistest or an early return; assigning to the variable in between ends the promotion. - A public or non-final field does not promote, because it could change or be overridden by a getter; copy it to a local, make it private and final (promotable since Dart 3.2), or use an if-case.
latemoves the initialisation check to run time;late finalallows one assignment, andlate final x = f()runsfonce on first read.- Reading a
latevariable before it is assigned throwsLateInitializationError, the run-time price of dropping the compile-time guarantee. - Prefer a nullable type when a value can be missing, so a forgotten case is a compile error; reserve
latefor values always set before use.
Terms in this lesson
- flow analysis
- The compiler's tracking of what must be true at each point in the code, used to promote types, check definite assignment and find unreachable code after an early return or a Never-returning call.
- late variable
- A variable declared with late: a non-nullable value whose initialisation check moves to run time. late final allows one assignment, and a late initializer runs lazily on first read.
- type promotion
- Flow analysis narrowing a variable's type after a check, such as treating a String? as a String once code has ruled out null, so later uses need no ! or cast.
References
- Sound null safety (The Dart project (Google))
- Understanding null safety (The Dart project (Google))
- Fixing type promotion failures (The Dart project (Google))
- Variables (The Dart project (Google))
- Announcing Dart 3.13 (The Dart project (Google))
Report a problem with this lesson
Kept only in this browser. Your Learn progress