Why Dart's Sound Null Safety Still Lets You Crash: A Practical Look at `late` and `!`
Dart's Sound Null Safety moves null errors to compile time, but the `!` and `late` escape hatches can reintroduce runtime crashes. Here is how to use them safely.
24 Sept 2026, 04:02 UTC

The 2 a.m. null crash that static analysis could have caught
You write a function that accepts a String. You are certain the caller will never pass null, so you skip the guard clause. In legacy Dart, that assumption survives until the app is in production, where it surfaces as a NoSuchMethodError deep in a stack trace. Sound Null Safety, introduced in Dart 2.12, promises to eliminate this class of failure by making the compiler enforce nullability rules before the code ever runs. The promise is real, but it comes with two sharp edges—the ! operator and the late keyword—that can quietly reintroduce the same crashes if you treat them as shortcuts rather than commitments.
The thesis is simple: Sound Null Safety shifts null-related failures from runtime to compile time, but only if you resist the temptation to force the type system with ! and late without genuine invariants.
Non-nullable by default: making the compiler your first reviewer
In a null-safe Dart program, every type is non-nullable unless you explicitly mark it otherwise. A variable declared as String name; can never hold null. If you try to assign null to it, the static analyzer—the tool that inspects your code without executing it—rejects the program at compile time. This is the core of soundness: the type system guarantees that a non-nullable reference will never be null at runtime, so you can remove defensive checks that were previously mandatory.
Static analysis catches these errors during development rather than in production. The practical benefit is not just fewer crashes; it is also less boilerplate. You no longer need to scatter if (value == null) guards through every function when the type system already proves the value exists.
Nullable types and the null-aware toolkit
Sometimes a value genuinely may be absent. Dart models this with the ? operator. A String? phoneNumber; tells the compiler—and every future reader—that null is a valid state. Once a type is nullable, you must handle the null case before you can use the value. Dart provides null-aware operators for this: ?. calls a method only if the receiver is non-null, and ?? supplies a fallback when the left-hand side is null. These operators make the null check explicit and local, which is far easier to audit than an implicit assumption buried in a comment.
The escape hatches: ! and late
Two features let you override the compiler's guarantees, and both carry runtime risk if your assumption is wrong.
The ! operator, called the null assertion operator, casts a nullable expression to its non-nullable form. It is a promise to the compiler: "I know this value is not null right now." If the promise is false, the program throws a runtime error. The late keyword defers initialization. It is useful for variables that cannot be set in a constructor but are guaranteed to be assigned before first use—for example, a dependency injected by a framework. If you read a late variable before it is assigned, Dart throws a LateInitializationError.
A worked example: when lazy initialization goes wrong
Consider a configuration class that loads settings asynchronously:
class AppConfig {
late final String apiUrl;
String? cachedToken;
void load() {
// Simulate async work
apiUrl = 'https://api.example.com';
}
String getEndpoint() {
// Dangerous: assumes apiUrl is initialized
return apiUrl;
}
String requireToken() {
// Dangerous: assumes cachedToken is non-null
return cachedToken!;
}
}
If getEndpoint() runs before load(), the late variable has not been assigned, and the program crashes with a LateInitializationError. If requireToken() runs while cachedToken is still null, the ! operator throws immediately. Neither failure is caught by the compiler because both rely on a runtime invariant the type system cannot verify.
The migration tax and the productivity trade-off
Sound Null Safety is not a drop-in upgrade for legacy projects. Migrating an existing codebase requires refactoring every nullable boundary: function parameters, return types, and fields. In mixed-mode programs—where null-safe and legacy libraries coexist—the compiler cannot guarantee soundness across the boundary, so some runtime checks remain. The migration effort is real, and teams often underestimate the time needed to satisfy the analyzer across a large codebase.
There is also a productivity paradox. The same escape hatches that make migration faster—sprinkling ! to silence the analyzer—can erode the stability gains. Every ! is a deferred runtime check. Every late is a promise that must be kept by every code path. Used sparingly and documented, they are valuable. Used as a default, they turn a compile-time guarantee back into a runtime gamble.
What to do next
If you are starting a new Dart project, keep null safety fully enabled and treat ! and late as code smells that require justification. If you are migrating, prioritize public API boundaries first; those are where nullability assumptions are most likely to break. To verify your current state, run dart analyze in your project root. You need read access to the project directory and the Dart SDK installed. The expected check is that the analyzer reports zero null-safety errors for correctly typed code; if it does not, it will identify the exact line where a nullable value is used without a check. Be aware that dart analyze will not flag a misplaced ! or an uninitialized late variable—those require unit tests or runtime assertions to catch.
Sound Null Safety does not eliminate all null bugs. It moves the ones you can prove into the compiler's hands and leaves the ones you assume in your own. The stability win comes from shrinking the second category as much as possible.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.