Sparround

Null safety basics

Under sound null safety the type system splits in two: non-nullable T and nullable T?. A String name can never be null — the compiler guarantees it, so no check is needed at the call site. A String? name may be null and the compiler requires a check before you use it.

The word "sound" matters here: the guarantee holds both at compile time and in the runtime type information — which is why the compiler can remove null checks entirely and generate faster code. This differs from TypeScript's strictNullChecks, where the check exists only at compile time.

The practical consequence: a null error stops being a "runtime surprise" and becomes a design decision. If a field is T?, it says "this value can genuinely be absent" and you chose that deliberately.

OperatorWhat it doesExample
`?.`Null-aware access — if the object is `null`, the result is `null``user?.name`
`??`Default value — returns the right side when the left is `null``name ?? 'Guest'`
`??=`Assigns only when the current value is `null``cache ??= compute();`
`!`Bang — asserts "this is not null"; throws at runtime if wrong`user!.name`
`?..`Null-aware cascade`user?..save()..log()`
`?[]`Null-aware index`list?[0]`

Flow analysis and promotion. The compiler tracks control flow: inside if (name != null) the variable name behaves as String rather than String? — this is type promotion. No ?. or ! is needed.

Promotion also works with early returns: after the line if (name == null) return;, name counts as promoted for all remaining code. That makes the guard-clause style particularly effective in Dart.

There are cases where promotion does not apply, and those are exactly what interviews ask about:

  • Public fields — neither a non-final instance field nor a getter is promoted, and a public final field is not promoted either, because a subclass could override it with a getter
  • Top-level and static variables
  • Local variables mutated inside a closure

The reason: the compiler only promotes a value it can guarantee will not change in between. For a public field there is no such guarantee — another method could reassign it, and a subclass could override it with a getter that returns a different value on each call.

There is one exception: since Dart 3.2, private `final` fields (final String? _token;) are promoted, because the compiler sees the whole library and knows the field cannot be overridden. The practical consequence: build your models out of private final fields and the need for ! shrinks on its own.

When is `!` a design smell? The bang tells the compiler "I know better". Sometimes you genuinely do (for example a local value checked one line above), but a rising density of ! usually signals that the model is wrong.

Typical causes and their proper fixes:

  • The field is T? because it is not ready in the constructor → `late final`, or two separate state classes
  • The field is T? because it is absent in some cases → separate those cases with a `sealed` hierarchy
  • You need a default when the value arrives as null`??`
  • You read a field and then use it → copy it into a local (final n = this.name; if (n != null) ...)

In practice teams pair the relevant lints with a review rule such as "a new ! requires a comment explaining why it cannot be null".

Interview tip. "Why is a nullable field not promoted while a local is?" is the single most asked null-safety question, and a surface answer ("that's how it works") is not enough. A strong answer explains the reason: "The compiler only promotes a value it can guarantee will not change in between. For a local it has that guarantee. A field can be reassigned from another method, a getter can return a different value on each call, and a subclass can override it — so two reads are unrelated." Then show two practical fixes: final local = field; if (local != null) { ... } gives you promotion without a single !; and making the field private and final, which since Dart 3.2 is promotable.

📚 Sources and documentation