Null Safety
Non-nullable by default
Section titled “Non-nullable by default”Dart has sound null safety: a type cannot hold null unless you explicitly say so with ?. This is a hard guarantee, not a lint — if the type system says a value is non-null, it genuinely cannot be null at runtime. dart.dev is explicit that this is no longer optional: “Dart has enforced sound null safety since Dart 3, released in May 2023.” — the old unsound/legacy mode this course’s SDK (Dart 3.13) could opt into no longer exists.
int a = 1; // non-nullable — can never be nullint? b = null; // nullable — the ? opts in to null
a = null; // ❌ compile error: a cannot be nullb = 42; // ✅ a nullable can also hold a real value“Sound” is the key word: because the guarantee is airtight, the compiler can optimize on it and you can trust it. A String name widget property will never surprise you with a null.
The four operators you’ll use daily
Section titled “The four operators you’ll use daily”String? maybeName;
// ?. — null-aware access: call only if not null, else the whole thing is nullfinal len = maybeName?.length; // int? — null if maybeName is null
// ?? — if-null: provide a fallback when the left side is nullfinal safe = maybeName ?? 'Guest'; // String — never null
// ??= — assign only if currently nullmaybeName ??= 'Default';
// ! — null assertion: "I promise this isn't null" (throws if you're wrong)final definite = maybeName!; // String — DANGER if it's actually nullThe ! operator is the one to respect: it silences the compiler by asserting non-null, and if you’re wrong it throws at runtime. Every ! is a small bet — use it only when you can prove the value is non-null.
The ! anti-pattern, proven with the real runtime error
Section titled “The ! anti-pattern, proven with the real runtime error”String shout(String? input) => input!.toUpperCase(); // the anti-pattern: a bet with no proofimport 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__null-safety/bang_operator.dart';
void main() { test('! is a runtime bet: it works when the value really is non-null', () { expect(shout('hi'), 'HI'); });
test('! throws a real, specific error when the bet is wrong', () { expect(() => shout(null), throwsA(isA<TypeError>())); try { shout(null); fail('should have thrown'); } catch (e) { expect(e.toString(), contains('Null check operator used on a null value')); } });}✓ ! is a runtime bet: it works when the value really is non-null✓ ! throws a real, specific error when the bet is wrong
00:00 +2: All tests passed!Null check operator used on a null value is one of the most common Flutter crash logs in production — it means a ! somewhere bet wrong. Every one of them is a ?./??/an explicit if (x == null) guard that didn’t get written.
Flow analysis narrows for you
Section titled “Flow analysis narrows for you”You rarely need !, because Dart’s flow analysis narrows a nullable to non-null after a check:
void greet(String? name) { if (name == null) return; // From here down, Dart KNOWS name is String (not String?) — no ! needed. print(name.toUpperCase());}flowchart TB
start["name: String?"] --> check{"name == null?"}
check -->|yes| ret["return early"]
check -->|no| promoted["name: String
(promoted, safe to use)"] Locals vs. fields — proven with a real error, and the Dart 3.2 exception
Section titled “Locals vs. fields — proven with a real error, and the Dart 3.2 exception”There’s a catch that bites Flutter devs: promotion normally works on local variables, not on instance fields or getters, because a field could change between the check and the use (another method could run in between). A public, mutable field is never promoted — the check does nothing for the following read, a real error:
// @expect-error real unchecked_use_of_nullable_value diagnostic, captured belowclass Greeter { String? name;
String greet() { if (name != null) { // ❌ `name` is a FIELD, not a local — flow analysis does not promote // it (it could change between the check and this read, e.g. via // another method or a getter with side effects). return 'Hello, ${name.length}'; // still String?, not promoted to String } return 'Hello, stranger'; }}error • The property 'length' can't be unconditionally accessed because the receiver can be 'null'.Try making the access conditional (using '?.') or adding a null check to the target ('!') •unchecked_use_of_nullable_valueThe traditional fix is copying the field to a local first, then checking the local (which the analyzer trusts, because a local truly can’t change out from under you mid-expression). But Dart 3.2 added a genuine exception, confirmed by dart.dev’s non-promotion-reasons page: a private, final field with no conflicting declarations is promotable, because none of those “it could change” risks apply to it:
class Greeter2 { final String? _name; // private + final — DOES get promoted (Dart 3.2+) Greeter2(this._name);
String greet() { if (_name != null) { return 'Hello, ${_name.length}'; // promoted to String here — no error } return 'Hello, stranger'; }}flutter analyze on Greeter2 reports zero issues — a real, current-Dart improvement over the older “always copy to a local” advice.
late: a promise to initialize before use
Section titled “late: a promise to initialize before use”late says “this will be non-null by the time anyone reads it, just not at declaration.” Useful for values set in initState or computed once — setState & the Lifecycle already proved the real runtime cost of breaking that promise (LateInitializationError) for a late final field left unassigned past initState.
late final String config; // no value yet, but promised before first read// If you read it before assigning, you get a LateInitializationError.Use late sparingly — it trades a compile-time guarantee for a runtime one. A misused late is just a ! in disguise.
Definite assignment analysis, proven with two real diagnostics
Section titled “Definite assignment analysis, proven with two real diagnostics”Without late, a non-nullable local that isn’t assigned on every path is a real, immediate compile error — Dart proves it can’t guarantee a value, and refuses. A non-final local gets one diagnostic:
// @expect-error real not_assigned_potentially_non_nullable_local_variable diagnostic, captured belowString describe(bool flag) { String label; // non-final, non-nullable, no initializer if (flag) { label = 'yes'; } return label; // no `else` — not assigned on every path}error • The non-nullable local variable 'label' must be assigned before it can be used. Try givingit an initializer expression, or ensure that it's assigned on every execution path •not_assigned_potentially_non_nullable_local_variableA final local with the same gap gets a different, real diagnostic — worded around “potentially unassigned” instead of “must be assigned”:
// @expect-error real read_potentially_unassigned_final diagnostic, captured belowString describe(bool flag) { final String label; // not-yet-assigned, non-nullable, final local if (flag) { label = 'yes'; } return label; // no `else` — not assigned on every path}error • The final variable 'label' can't be read because it's potentially unassigned at this point.Ensure that it is assigned on necessary execution paths • read_potentially_unassigned_finalBoth are fixed the same way: assign on every path (add the else), or give an initializer up front.
Null-aware patterns and collections
Section titled “Null-aware patterns and collections”Patterns (previous lesson) have their own null-aware forms, plus a null-aware spread for collections:
// Null-check pattern: `var x?` matches only if the value is non-null, and// binds `x` with the non-null type — the pattern-matching equivalent of `!`,// but as a conditional match instead of a throwing assertion.String describe(int? maybeValue) => switch (maybeValue) { var x? => 'value: $x', // matches non-null, binds int (not int?) null => 'nothing',};
// Null-assert pattern: `expr!` inside a pattern — asserts non-null the way// the `!` operator does, but as part of destructuring.(int, int) unwrapPair((int?, int?) pair) { final (a!, b!) = pair; // throws if either half is null return (a, b);}
// Null-aware spread (`...?`) and null-aware collection-if guard against a// nullable list contributing nothing instead of throwing.List<String> merge(List<String>? maybeExtra) => [ 'base', ...?maybeExtra, // spreads only if non-null; a bare `...` would throw on null];import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__null-safety/null_patterns.dart';
void main() { test('null-check pattern (var x?) matches non-null and binds the promoted type', () { expect(describe(5), 'value: 5'); expect(describe(null), 'nothing'); });
test('null-assert pattern (a!) destructures and throws on a null field', () { expect(unwrapPair((1, 2)), (1, 2)); expect(() => unwrapPair((null, 2)), throwsA(isA<TypeError>())); });
test('null-aware spread (...?) contributes nothing instead of throwing on null', () { expect(merge(['x', 'y']), ['base', 'x', 'y']); expect(merge(null), ['base']); });}✓ null-check pattern (var x?) matches non-null and binds the promoted type✓ null-assert pattern (a!) destructures and throws on a null field✓ null-aware spread (...?) contributes nothing instead of throwing on null
00:00 +3: All tests passed!List<String?> vs List<String>?, and required named parameters
Section titled “List<String?> vs List<String>?, and required named parameters”These two types are genuinely different, and easy to swap by accident:
// `List<String?>` — a non-nullable list whose ELEMENTS may be null.// `List<String>?` — a nullable list whose elements, if the list exists, are// never null. These are genuinely different types; do not confuse them.
int countNulls(List<String?> items) => items.where((e) => e == null).length;
int lengthOrZero(List<String>? maybeItems) => maybeItems?.length ?? 0;
String greetAll({required String from}) => 'from $from';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__null-safety/list_nullability.dart';
void main() { test('List<String?>: the list always exists, but elements may be null', () { expect(countNulls(['a', null, 'b', null]), 2); });
test('List<String>?: the list itself may be null, but elements never are', () { expect(lengthOrZero(['a', 'b']), 2); expect(lengthOrZero(null), 0); });
test('a required named parameter must be supplied — see the lesson for the real missing_required_argument error', () { expect(greetAll(from: 'Ada'), 'from Ada'); });}✓ List<String?>: the list always exists, but elements may be null✓ List<String>?: the list itself may be null, but elements never are✓ a required named parameter must be supplied — see the lesson for the real missing_required_argument error
00:00 +3: All tests passed!Skipping the required argument is a real, immediate compile error, not a runtime surprise:
// @expect-error real missing_required_argument diagnostic, captured belowimport 'list_nullability.dart';
void demo() { greetAll(); // missing the required `from` argument}error • The named parameter 'from' is required, but there's no corresponding argument. Try addingthe required argument • missing_required_argumentReading AI-generated code
Section titled “Reading AI-generated code”AI-generated Dart under time pressure defaults to ! far more often than a human reviewer would accept — it’s the fastest way to make a red squiggle disappear, and it compiles. The cost shows up later, in production crash logs, not in the editor.
// AI-generated: "get the user's display name"String displayName(Map<String, dynamic> json) { return json['profile']['displayName'].toString(); // Three unguarded assumptions stacked: 'profile' exists, it's a Map, // and 'displayName' exists on it. Any missing key throws a real, // unhandled runtime error — not the friendly Null check operator // message, but a TypeError on the failed cast/access chain.}Before trusting generated code like this, check for:
- Every
!— can you point to the specificif/??/prior validation that actually proves non-null here? If not, it’s the anti-pattern proven above, one bad API response away from a real crash. - Chained
map['a']['b']['c']access ondynamic/Map<String, dynamic>JSON — each[]can returnnull, and there’s no compiler check at all ondynamic; prefer a typed model withfromJsonvalidation at the boundary, once. - A field read directly after a null-check on
this.field(orwidget.field) instead of copying to a local first — proven above, this is a real analyzer error, not just a style nit, unless the field happens to be private andfinal(the Dart 3.2 exception, also proven above). - A function accepting
List<String>?when it actually means “always a list, but some entries might be missing” (List<String?>) — the two are not interchangeable, proven above.
🐛 Spot the bug
class OrderSummary {OrderSummary({required this.total, this.discountCode});final double total;final String? discountCode;
String label() { if (discountCode != null) { return '$total (code: ${discountCode.toUpperCase()})'; } return '$total';}}Show the answer
This does not compile: discountCode is checked with if (discountCode != null), but it’s read inside a string interpolation, \${discountCode.toUpperCase()} — the same field-promotion gap proven earlier in this lesson, since discountCode is a public field (not private+final), so the null check does not narrow it for the read inside the interpolation. The real error is unchecked_use_of_nullable_value. The fix is copying it to a local first: final code = discountCode; if (code != null) { ...code.toUpperCase()... } — now the analyzer trusts the local can’t change between the check and the use.
Mental Model
Section titled “Mental Model”Null safety isn’t “add ? everywhere until it compiles” — it’s a promise system the compiler checks for you. Every ? is a value you’re admitting might not exist; every non-nullable type is a promise you (or the API you’re calling) are making that it always will. ! cashes in that promise without proof — a bet. Flow analysis is the compiler automatically proving the promise is kept, for the narrow, safe case of a local variable that provably can’t change out from under you between the check and the use — which is exactly why it doesn’t (usually) extend to fields, and exactly why Dart 3.2 could extend it to private final fields once it could prove those also can’t change.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
! | Fewest keystrokes, compiles immediately | Proven above: a real, unhandled TypeError crash the moment the bet is wrong — no compiler safety net left |
?. / ?? | Never throws; degrades gracefully | Can silently mask a bug that should have been loud (a value that’s null when it truly never should be) |
| Copy-to-local for field promotion | Works for any field, always | Extra line, extra variable to keep in sync — the private-final-field promotion (Dart 3.2) removes this cost for the subset of fields that qualify |
late | Defers initialization past declaration, still statically non-nullable everywhere it’s used | Downgrades the guarantee to runtime — reading too early throws LateInitializationError, proven in the lifecycle lesson |
Null-check pattern (var x?) | Matches conditionally, no throw on the non-match branch | Only available inside a pattern context (switch, if-case) — not a drop-in replacement for ! everywhere |
Common Misconceptions
Section titled “Common Misconceptions”- “Dart still has an unsound/legacy null-safety mode you can opt into.” Disproven above with the direct dart.dev quote: sound null safety has been enforced, unconditionally, since Dart 3 (May 2023) — there is no opt-out in this course’s Dart 3.13.
- “If I check
if (this.field != null), the field is safe to use right after, just like a local.” Disproven above with a realunchecked_use_of_nullable_valueerror — true only since Dart 3.2, and only for a private,finalfield with no conflicting declarations; a public or mutable field is never promoted. - “
List<String?>andList<String>?mean basically the same thing.” Disproven above: one is a list that always exists but may contain null entries; the other may itself be null but never contains a null entry —countNullsandlengthOrZeroabove have genuinely different, non-interchangeable signatures. - “A missing
requiredargument is a runtime null-pointer-style crash, same category as!.” Disproven above: it’s a real, immediate compile-time error (missing_required_argument) — Dart never lets the program run with the argument missing, unlike!, whose failure is deferred to whenever that code path actually executes.