From Legacy to Null‑Safe: A Practical Migration Playbook for Dart
Migrating a Dart codebase to null safety: step‑by‑step guidance, common pitfalls, and best practices. Learn how to use <code>dart migrate</code>, resolve manual edits, and keep tests green.
12 Aug 2025, 16:26 UTC

Why Null Safety Matters
When Dart introduced null safety, it flipped the default type system from permissive to strict. Instead of silently allowing a Null value, every variable now must be explicitly declared as nullable with a trailing ?. This change cuts a class of runtime errors—NullPointerException‑like crashes—by catching them at compile time. For a codebase that grew over years of rapid iteration, the migration can feel daunting, but the payoff in reliability and developer confidence is worth the effort.
Concrete Problem: Legacy Code that Tolerates Nulls
Your repository contains modules that treat null as a sentinel value: a missing user name defaults to null, a lookup returns null to signal “not found,” and a list is sometimes null instead of empty. Running tests with the old type system passes, but any runtime path that dereferences a null will crash. The goal is to bring the entire codebase into the new non‑nullable world while preserving original semantics where null was intentional.
Thesis: Use dart migrate as the backbone, but stay vigilant for edge cases.
The migration tool automates the bulk of the refactor: it rewrites type annotations, adds null‑aware operators, and flags problematic patterns. However, the tool cannot magically understand every business rule. A disciplined, step‑by‑step approach ensures that the migration finishes cleanly, tests still pass, and you avoid breaking logic that relies on null as a meaningful value.
Step 1 – Prepare a Clean Branch
- Make a fresh clone or create a new branch:
git checkout -b migrate-null-safety - Run
dart pub getto install dependencies. If any package is missing null safety support, note it for later. - Verify current test coverage:
dart test --coverage=coverage/. Keep this baseline for comparison.
Step 2 – Run the Migration Tool
Invoke the migration on the entire repository:
dart migrate --apply-changes
Key points:
- Permissions: You need write access to the project files.
- Placeholders: If you see
/* TODO: verify */comments, those are spots the tool couldn’t resolve automatically. - Expected checks: The tool will print a summary of changes and any files that require manual edits.
- Risks: The tool may add
lateor! operatorin places that could still throw. Review carefully.
Step 3 – Resolve Manual Edits
Open the files flagged by the tool. Common patterns include:
var user = findUser(id); // returns User?→final User? user = findUser(id);- Using
! operatoron a value that might benullin production. Replace with a null‑aware operator or guard clause. - Replacing
List<T>? items;withList<T> items = [];if the intent is an empty list.
Step 4 – Update Dependencies
Check the pubspec.yaml for any dependencies that lack null safety. You have two options:
- Upgrade to a null‑safe version if available:
dart pub upgrade --major-versions. - Use the
dependency_overridessection to pin a null‑safe fork or an older version that already supports it.
Run dart pub get again to apply changes.
Step 5 – Run the Test Suite
Execute all tests to confirm behavior:
dart test
Compare the new test coverage report with the baseline. Pay particular attention to any tests that now fail or produce warnings about null dereferences. Fix the underlying logic before proceeding.
Concrete Worked Example
Consider a legacy function that returns a user’s email or null if the user is not found:
String? getEmail(String userId) {
final user = findUser(userId); // User? from legacy code
return user?.email;
}
After running dart migrate, the tool might produce:
String? getEmail(String userId) {
final User? user = findUser(userId);
return user?.email;
}
Now, to eliminate the nullable return type, decide whether null is a valid business signal. If you want to return an empty string instead, refactor manually:
String getEmail(String userId) {
final User? user = findUser(userId);
return user?.email ?? '';
}
Run dart test again to ensure the change does not break existing callers.
Trade‑Offs and Limitations
- Legacy Null Semantics: If your code intentionally uses
nullto represent “unknown” or “not applicable,” you must refactor those patterns. Otherwise, the migration will either throw or silently replacenullwith defaults, potentially altering business logic. - Dependency Hell: Some third‑party packages may not yet ship a null‑safe release. You can temporarily vendor the package or use a
dependency_overridespointing to a null‑safe fork, but this adds maintenance overhead. - Tool Accuracy:
dart migrateis powerful but not perfect. It may leavelateor! operatorin place where a more nuanced guard is needed. Manual review is mandatory.
Actionable Checklist
- Create a dedicated migration branch.
- Run
dart migrate --apply-changesand address manual edits. - Update all dependencies to null‑safe versions.
- Run
dart testafter each major change. - Use
dart formatto tidy code formatting. - Compile the release:
dart compile exe bin/main.dartto ensure the entire app builds. - Merge back once all tests pass and manual review is complete.
By following this disciplined approach—tool automation, manual scrutiny, and rigorous testing—you’ll transition your Dart codebase to null safety with confidence and minimal disruption.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.