Dart (3.13) Module 4 – Sound null safety
Sound null safety: nullable types and operators
What sound null safety guarantees and since when, how to replace a risky null assertion, the null-aware operators, and Dart 3.8 null-aware elements.
What you will learn
- Explain what sound null safety guarantees and since when Dart enforces it
- Rewrite a ! that may throw into a safe check with ??, promotion or an if-case
- Use the null-aware operators ?., ?[], ?? and ??=
- Build collections with Dart 3.8 null-aware elements
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what sound null safety guarantees and since when Dart enforces it
- Rewrite a ! that may throw into a safe check with ??, promotion or an if-case
- Use the null-aware operators ?., ?[], ?? and ??=
- Build collections with Dart 3.8 null-aware elements
Before you start
On this page
In Dart a value whose type is String can never be null; only String? admits it. That one rule, enforced by the
compiler, is sound null safety, and it turns a whole class of run-time crashes into errors you see before the program
runs. The cost is a handful of operators for the places where a value genuinely might be absent.
You will see what non-nullable means and since when Dart enforces it, read the error a ! throws and three safer ways
to write it, use the null-aware operators, and build a query whose unset filters simply disappear with Dart 3.8
null-aware elements.
Non-nullable by default
A type is non-nullable unless you add ?. String name must always hold a string; String? note may hold a string
or null. This is sound null safety: Dart's guarantee that a value whose type is not nullable is never null, checked by the compiler and still true while the program runs. A type allows null only when it says so, as String? does. More in “What Dart is and where it runs”, and “sound” is the strong word:
the understanding-null-safety page explains that a value whose static type is non-nullable can never be null at run
time, which also lets the compiler drop null checks it knows can never fire. Dart has enforced it since Dart 3, which
arrived in 2023 and dropped the language versions before 2.12. The history is short but worth seeing:
- 2.12
Null safety, opt-in opt-in
Types became non-nullable by default for packages that opted in, with String? for values that may be null and the sound guarantee behind it.
- 3.0
Enforced for everyone mandatory
Dart 3, released in 2023, made sound null safety mandatory and dropped support for language versions before 2.12, so every program is now null-safe.
- 3.8
Null-aware elements new syntax
Collections gained ?expr, which drops a list element or map entry when it is null, so building a query from optional filters needs no if per entry.
- 3.9
Flow analysis assumes null safety analysis
Promotion, reachability and definite assignment now assume null safety, so an upgrade may surface more dead_code warnings where a null check can never fail.
The timeline has 4 events, from 2.12 to 3.9.
Because the guarantee is sound rather than best-effort, you can trust a non-nullable type the way you trust an int to
be a number: the type system has already ruled out the absent case.
What you give up is the freedom to leave a value unset by accident; what you gain is that the compiler, not a run-time
crash, tells you where a value might be missing. A field that can legitimately be absent is declared nullable, such as
String? middleName, and every reader then has to handle the null before using it. A field that must always be present
stays non-nullable, and the compiler refuses any path that could leave it null. This turns “could this be null here?”
from a question you carry in your head into one the analyzer answers, and it is why reading a ? in a signature is
useful information rather than noise.
Checkpoint
Check: Non-nullable by default
The null-assertion operator can throw
The ! operator takes a nullable value and asserts it is not null, giving back the non-nullable type. When the value
is null, it throws. bang.dart reads a key that is not in the map and asserts it:
// The ! operator asserts that a value is not null. When it is, the program
// throws at run time.
void main() {
final prices = {'tea': 10};
final coffee = prices['coffee']!;
print(coffee);
} Output (exit status 255)
Printed as an error (standard error)
Unhandled exception: Null check operator used on a null value #0 main (bang.dart:5:34) #1 _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:313:19) #2 _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 bang.dart
It threw Null check operator used on a null value and ended with status 255. Each ! is a promise the compiler cannot
check, so outside tests it is a code smell: it says “I know better than the type system”, and a wrong guess crashes.
safe.dart replaces it three ways:
// Three safe ways to read a possibly-missing value instead of the ! operator.
void main() {
final prices = {'tea': 10};
// 1. ?? supplies a fallback when the lookup is null.
final coffee = prices['coffee'] ?? 0;
print('coffee: $coffee');
// 2. An if that checks for null promotes the variable to a non-null type.
final tea = prices['tea'];
if (tea != null) {
print('tea: ${tea + 5}');
}
// 3. An if-case pattern binds only when the value is not null.
if (prices['tea'] case final t?) {
print('tea again: $t');
}
} Output
coffee: 0 tea: 15 tea again: 10
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run safe.dart
It printed coffee: 0, tea: 15 and tea again: 10. The ?? operator supplied 0 when the lookup was null;
assigning the lookup to tea and testing if (tea != null) promoted tea to a plain int inside the block; and the
if-case prices['tea'] case final t? bound t only because the value was not null. Step through the contrast:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The lookup is nullable. A map lookup returns a nullable type. prices['coffee'] has type int?, and the coffee key is missing, so the value is null.
- asserts it is not null. Writing prices['coffee']! tells the compiler the value is not null. Because it is, bang.dart threw Null check operator used on a null value and stopped.
- ?? supplies a fallback. prices['coffee'] ?? 0 gives 0 when the lookup is null, so safe.dart printed coffee 0 and kept running. The fallback replaces the ! and cannot throw.
- Or promote with an if. Assigning the lookup to a local and testing it with if (tea != null) promotes tea to a plain int inside the block, so tea + 5 needs no ! at all.
Checkpoint
Check: The null-assertion operator
Null-aware operators
For the places where null is expected, Dart has a family of null-aware operator: An operator that works on a value that may be null: ?? and ??= supply a fallback, and ?. and ?.. skip the member access or the cascade when the receiver is null. More in “Operators, cascades and equality in Dart”
that do nothing, or supply a value, when the receiver is null. They save the nested if checks that clutter code in
languages without them. This table lists the ones you reach for most:
| Operator | On a null receiver | Example |
|---|---|---|
?. |
skips the member access, gives null | user?.name |
?[] |
skips the index, gives null | list?[0] |
?? |
uses the right-hand fallback | name ?? 'guest' |
??= |
assigns only if currently null | cache ??= compute() |
...? |
spreads only if the collection is non-null | [...?extra] |
?.. |
skips the cascade | builder?..add(x) |
The map lookup in safe.dart returned a nullable int, which is exactly where ?? fits; m['k'] ?? fallback is the
everyday replacement for m['k']!. Reach for containsKey or an if-case only when null is itself a valid stored
value and you must tell “missing” from “present and null” apart.
Checkpoint
Check: Null-aware operators
Null-aware elements build collections
Dart 3.8 extended null-awareness into collection literals. A ?expr element is included only when it is not null, so a
list or map built from optional parts drops the missing ones without an if for each. searchparams.dart builds a query
this way:
// Null-aware elements (Dart 3.8): ?expr in a collection drops the element when
// it is null, so unset filters simply disappear.
void main() {
String? coupon;
const order = 'order-42';
const gift = 'gift';
final items = [order, ?coupon, gift];
print(items);
String? city;
const q = 'chai';
final params = {
'q': q,
'city': ?city,
};
print(params);
} Output
[order-42, gift]
{q: chai}
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run searchparams.dart
The list [order, ?coupon, gift] printed [order-42, gift]: coupon was null, so ?coupon contributed nothing. The
map wrote 'city': ?city, and because city was null the whole city entry vanished, leaving {q: chai}. This is
how you turn a set of optional filters into a query with no empty keys: each optional value is a null-aware element, and
only the ones that are set appear.
Which null-aware form to use
Use ...? to splice in a whole list that might be null, and ?expr for a single element or entry that might be
null. Both leave the result free of nulls, so the collection’s type stays non-nullable.
Checkpoint
Check: Null-aware elements
Never, and the error that keeps nulls out
The rule has teeth at compile time. assign_error.dart tries to put a String? into a String:
// A nullable value cannot be assigned to a non-nullable variable; the compiler
// rejects it.
String shout(String? maybe) {
String sure = maybe;
return sure.toUpperCase();
}
void main() => print(shout('asha')); Output (exit status 254)
Printed as an error (standard error)
assign_error.dart:4:17: Error: A value of type 'String?' can't be assigned to a variable of type 'String'.
String sure = maybe;
^
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run assign_error.dart
The compiler refused it with “A value of type ‘String?’ can’t be assigned to a variable of type ‘String’” and status
254: you must handle the null first. At the other end sits Never, the type of an expression that never completes
normally, such as a function that always throws. never.dart uses one:
// A function that always throws returns Never. Flow analysis knows the code
// after a Never call is unreachable, so n is a non-null int below the if.
Never fail(String message) => throw StateError(message);
int parsePort(String s) {
final n = int.tryParse(s);
if (n == null) fail('not a number: $s');
return n;
}
void main() {
print(parsePort('8080'));
try {
parsePort('oops');
} on StateError catch (e) {
print('caught: ${e.message}');
}
} Output
8080 caught: not a number: oops
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run never.dart
It printed 8080, then caught: not a number: oops. Because fail returns Never, the compiler knows the code after
if (n == null) fail(...) runs only when n is not null, so n is promoted to a plain int at return n, with no
!. Never is how a throw-helper keeps flow analysis happy, a thread
type promotion, late and definite assignment picks up in full.
Checkpoint
Check: Never and the assignment rule
Practise: remove every !
Take a function that over-uses ! and make it safe.
- Start from a small order-summary function that reads several nullable fields with
!, such asorder.coupon!andorder.note!, and currently crashes when any is missing. - Replace each
!: use??for a fallback, promotion with anifwhere the value is used several times, and a null-aware element when building the summary list. - Run it with every field set, then with each field null in turn, and confirm it never throws.
- As a check, make sure
dart analyzereports no use of!left in the function.
When it survives every null, 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): What does the ! operator do, and why is it called a code smell?
The ! operator takes a nullable value and asserts to the compiler that it is not null, giving back the non-nullable
type so you can use it. It checks nothing itself: if the value is null at run time, it throws Null check operator used on a null value, as bang.dart did. It is called a code smell because each ! is a promise the compiler cannot verify,
so it moves a guarantee from compile time, where null safety usually catches mistakes, back to run time, where a wrong
guess becomes a crash. It is fine in a test, where throwing is the point, or right after a check that the analyzer
cannot follow. Everywhere else, prefer ?? for a fallback, an if that promotes the value, or an if-case pattern, so
the null is handled rather than asserted away.
The bank adds two questions that carry null safety into real cleanup and design work:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A screen crashes with "Null check operator used on a null value" for some users but not others. The code reads user profile fields with ! throughout, such as user.phone! and user.address!.city!. Walk through how you would find the crash and remove the ! operators without changing behaviour for users whose fields are set.
-
Mid to senior
A teammate used a language whose nullability was only a warning and asks what "sound" buys that is worth the extra ? and ! in the code. Explain what soundness guarantees, one thing it lets the compiler do, and one class of bug it removes, with a concrete example.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which Dart types can hold null?
Only nullable types, written with ?, such as String?. Types are non-nullable by default, and Dart has enforced this since Dart 3.
-
What does "sound" add to Dart's null safety?
A non-nullable value can never be null while the program runs, so you can trust the type and the compiler can drop checks.
-
What does the ! operator do, and what happens when it is wrong?
It asserts that a nullable value is not null and gives back the non-nullable type. When the value is null it throws "Null check operator used on a null value", so a compile-time guarantee becomes a run-time crash.
-
What can replace a ! in ordinary code?
A fallback with ??, promotion after a null check, or an if-case. Keep ! for tests, or right after a check that the analyzer cannot follow.
-
What does each null-aware operator do when the receiver is null?
?. and ?[] skip the access and give null, ?? uses the fallback on its right, ??= assigns only if the variable is null, ...? spreads nothing and ?.. skips the cascade.
-
When do you use ...? and when ?expr in a collection literal?
...? splices in a whole list that might be null, and ?expr, a null-aware element since Dart 3.8, adds one element or entry that might be null. Both leave nulls out, so the collection's type stays non-nullable.
-
How does a throw-helper typed Never help flow analysis?
Never is the type of an expression that never completes normally, such as a function that always throws. After if (n == null) fail(...), the compiler knows n is not null, so it promotes n to int with no !.
Key takeaways
- Types are non-nullable by default; only a
?type such asString?admits null, and Dart has enforced this sound guarantee since Dart 3. - “Sound” means a non-nullable value can never be null at run time, so the compiler can drop checks and you can trust the type.
!asserts non-null and throws when wrong (Null check operator used on a null value); replace it with??, promotion or an if-case, and keep it for tests.- The null-aware operators
?.,?[],??,??=,...?and?..act only when the receiver is non-null, and Dart 3.8 null-aware elements (?expr) drop null entries from a collection. - Assigning
String?toStringis a compile error, andNever, the type of a throw-helper, lets flow analysis promote a value after the helper is called.
Terms in this lesson
- Never
- The type of an expression that never completes normally, such as a function that always throws or exit(). A call to a Never-returning function lets flow analysis treat the following code as unreachable.
- null-assertion operator
- The postfix ! operator, which asserts a nullable value is not null and returns the non-nullable type. If the value is null at run time it throws, so each use is an unchecked promise.
- nullable type
- A type written with a trailing ?, such as String?, whose values may be null. A type without ? is non-nullable and can never hold null under sound null safety.
References
- Sound null safety (The Dart project (Google))
- Understanding null safety (The Dart project (Google))
- Operators (The Dart project (Google))
- Collections (The Dart project (Google))
- Dart SDK changelog (The Dart project (Google))
Report a problem with this lesson
Kept only in this browser. Your Learn progress