Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 10 minutes
  • Examples run with dart 3.13.5 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

promote.dart: promotion after a null check and an is test Dart · promote.dart
// 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:

Interactive · step by step A null check promotes s from String? to String Step through lengthOrZero('hello'). The early return removes the null case, so s becomes a plain String.
A null check promotes s from String? to String, step 4 of 4. Showing s: String?, if (s == null) return 0, s: String, s.length. In focus: s.length.s: String?if (s == null) return 0s: Strings.length

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. s may be null. s arrives with type String?, so reading s.length directly is a compile error; the value might be null.
  2. 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.
  3. 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.
  4. 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

  1. Question 1 of 2 After if (s == null) return 0; what is the type of a String? parameter s below it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: String

    promote.dart called s.length after the guard because s was promoted to String; lengthOrZero('hello') gave 5.

  2. Question 2 of 2 What ends the promotion of a local variable?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Assigning a new value to it between the check and the use

    Promotion lasts until the variable is reassigned; after s = something() you must check again.

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:

field_promote.dart: a public field will not promote Dart · field_promote.dart
// 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:

field_fix.dart: a local copy, a private final field and an if-case Dart · field_fix.dart
// 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:

Timeline · 3 events in order Type promotion across Dart versions Walk the versions to see when fields gained promotion and the later fix.
  1. 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.

  2. 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. 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

  1. Question 1 of 2 Why does a public nullable field not promote after an if (field != null) check?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Another part of the program could change it, or a getter could override it, between check and use

    field_promote.dart failed because nickname is a public property that could change; the error links to non-promo-public-field.

  2. Question 2 of 2 Which change lets a nullable field be used after a null check without a !?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Copy it into a local variable first

    field_fix.dart copied the field to a local in greetLocal; a private final field or an if-case also work.

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:

late.dart: a late final initializer runs once, on first read Dart · late.dart
// 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

  1. One question When does the initializer of late final x = load(); run?

    Choose one answer.

    Show the answer: One question

    Answer: On the first read of x

    late.dart ran _load at the first read of config and reused the result, so loading config printed once.

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:

late_error.dart: reading an unset late field Dart · late_error.dart
// 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

  1. One question What happens when you read a late field that was never assigned?

    Choose one answer.

    Show the answer: One question

    Answer: It throws a LateInitializationError at run time

    late_error.dart threw LateInitializationError, Field 'token' has not been initialized, with status 255.

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

  1. One question A value can legitimately be absent for some users. Nullable type or late?

    Choose one answer.

    Show the answer: One question

    Answer: A nullable type, so a forgotten case is a compile error

    Reserve late for values always set before use; use a nullable type when absence is legitimate.

Practise: fix a Session with late fields

Repair a class whose late fields crash for a logged-out user.

  1. Start from a Session with several late fields such as late String token; and late User user;, where a logged-out session never sets them, so reading one throws LateInitializationError.
  2. 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.
  3. Update the readers: a nullable field needs promotion with a local copy or an if-case, not a !.
  4. Run it for a logged-in and a logged-out session and confirm neither throws, and that dart analyze reports 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.

  1. Question 1 of 5 Inside if (o is int) { … }, what can you do with o?

    Choose one answer.

    Show the answer: Question 1 of 5

    Answer: Use it as an int, such as o + 1

    promote.dart returned int 42 from describe(41) because o was promoted to int after o is int.

  2. Question 2 of 5 Since Dart 3.2, which field can be promoted directly after a null check?

    Choose one answer.

    Show the answer: Question 2 of 5

    Answer: A private final field that no other declaration in the library shadows

    field_fix.dart's greetDirect relied on the private final _nickname promoting, which Dart 3.2 allows.

  3. Question 3 of 5 What does late final config = load(); give you over final config = load();?

    Choose one answer.

    Show the answer: Question 3 of 5

    Answer: load runs lazily on first read, not when the declaration is reached

    late.dart deferred _load until config was first read, printing loading config after main starts.

  4. Question 4 of 5 What is a LateInitializationError a sign of?

    Choose one answer.

    Show the answer: Question 4 of 5

    Answer: A late variable was read before it was assigned

    late_error.dart read token before assigning it, so the late check threw at run time.

  5. Question 5 of 5 A field is wired up by dependency injection and always set before any use. Nullable or late?

    Choose one answer.

    Show the answer: Question 5 of 5

    Answer: late, because it is genuinely always set before use

    late fits the always-set-before-use case; a nullable type is for values that can legitimately be absent.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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, an is test 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.
  • late moves the initialisation check to run time; late final allows one assignment, and late final x = f() runs f once on first read.
  • Reading a late variable before it is assigned throws LateInitializationError, 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 late for 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.