Adopting Dart Sound Null Safety: An Architecture Note
A concise architecture‑level guide for adopting Dart sound null safety, covering requirements, minimal design, data boundaries, CI checks, failure modes, and triggers for redesign.
08 Apr 2026, 11:34 UTC

Requirements
To enable sound null safety in a Dart project you must satisfy four concrete conditions:
- Set the Dart SDK constraint in
pubspec.yamlto>=2.12.0(or higher). - Migrate all source files so that every variable, parameter, and return type is either non‑nullable by default or explicitly marked with
?when absence of value is meaningful. - Ensure every direct and transitive dependency declares null safety in its own
pubspec.yaml; mixed‑mode packages re‑introduce runtime null errors. - Enable the analyzer with strict hints (e.g.,
--fatal-infos --fatal-warnings) so that any remaining unsoundness is reported as a build failure.
These items are prerequisites; without them the compiler cannot guarantee the soundness property that NullThrownError cannot be thrown at runtime.
Smallest Suitable Design
The null‑safe type system treats every type as non‑nullable unless you annotate it with ?. The minimal viable design therefore follows two rules:
- Prefer non‑nullable types. Only introduce
T?when the absence of a value is a legitimate state (e.g., optional query parameters, lazily loaded resources). - Use the null‑aware operators
?.for safe member access and??for providing a fallback value.
This keeps the mental model simple: you read a variable and know it cannot be null unless you explicitly opted‑in.
Trust and Data Boundaries
Data that originates outside the Dart VM—JSON payloads, platform channels, FFI pointers, or user input—cannot be trusted to respect your null‑safety annotations. The boundary is the place where you convert raw data into Dart objects.
A concrete example is decoding JSON from an HTTP endpoint:
import 'dart:convert';
class User {
final String id;
final String? email; // email may be absent
User({required this.id, this.email});
factory User.fromJson(Map<String, dynamic> json) {
// Validate at the boundary
final String id = json['id'] as String?
?? throw FormatException('Missing or null id');
final String? email = json['email'] as String?; // may be null
return User(id: id, email: email);
}
}
void main() async {
final response = await http.get(Uri.parse('https://api.example.com/users/1'));
final Map<String, dynamic> payload = jsonDecode(response.body) as Map<String, dynamic>;
final User user = User.fromJson(payload);
// At this point `user.email` is safely nullable; using ?? provides a default
final String display = user.email ?? 'no email provided';
print(display);
}
The factory checks that id is present and non‑null; if it is missing or null it throws a FormatException before any Dart object is created. This prevents a later NullThrownError from propagating through the rest of the program.
Operational Checks
Integrate the following verification steps into your CI pipeline to catch regressions early:
- SDK version: Run
dart --versionin the repository root and assert the output starts withDart SDK version: 2.12.0or higher. - Analyzer strictness: Execute
dart analyze --fatal-infos --fatal-warnings. The command should exit with status 0; any hint, warning, or error indicates a null‑safety gap. - Dependency audit: After
dart pub get, rundart pub outdated --mode=null-safety. The list should be empty; any package shown needs a null‑safe upgrade or a fork. - Formatting and fixes: Optionally run
dart format .anddart fix --applyas part of pre‑submit hooks to keep the codebase tidy and to apply suggested migrations automatically.
All of these commands require only read access to the project files; they do not modify state unless you explicitly invoke dart fix --apply. The risk is low, but be aware that dart fix may rewrite code in ways that change behavior if the analyzer’s suggestions are based on incomplete context.
Failure Modes
Even after meeting the requirements, certain patterns can re‑introduce runtime null errors:
- Late variables without initialization: Declaring
late String name;and accessingnamebefore assigning it throwsNullThrownError. Ensure everylatefield is initialized before first use, or prefer a regular nullable field with explicit checks. - Missing nullable annotations: If a field that can legitimately be null is left as non‑nullable, the analyzer will not complain, but a JSON decoder or FFI call may store null, causing a cast failure at runtime.
- Unsound casts via
asordynamic: Usingjson['value'] as Stringwhen the source may be null bypasses the type system. Replace with explicit null‑aware checks or useas String?followed by a null‑assertion only after validation.
These failure modes are detectable by the analyzer when strict hints are enabled, but they can slip through if you rely on dynamic to silence warnings.
When the Design May Need to Change
Certain external shifts can invalidate the assumptions baked into the minimal design:
- New platform without null‑safe bindings: If you target a Flutter plugin or Dart FFI library that has not been migrated, you must treat data from that boundary as potentially null and add validation wrappers.
- Legacy SDK constraint: A requirement to support Dart 2.10 or earlier forces you to opt‑out of null safety (
sdk: <2.12.0). In that case you cannot rely on soundness guarantees and must revert to manual null checks. - Performance‑critical FFI sections: In tight loops where null checks add measurable overhead, you may choose to use
Pointer‑based access and manually validate inputs before entering the loop. This is an intentional, localized unsoundness that should be documented and tested.
When any of these conditions arise, revisit the trust/data boundaries step: introduce explicit validation, consider wrapping unsound calls in a safe façade, and update the operational checks to reflect the new constraints.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.