Migrating Legacy Dart Code to Null Safety: A Practical Walkthrough
A hands-on guide to migrating a legacy Dart module to null safety: run the automated tool, resolve the remaining warnings, refactor a real service method, and verify the result—without a full rewrite.
29 Nov 2025, 23:31 UTC

The Problem: Null Dereferences in Production
You've inherited a Dart codebase that crashes with NoSuchMethodError: The method '...' was called on null at the worst possible times. The stack traces point to harmless-looking property accesses, but the root cause is always the same: a variable that should have a value doesn't. Dart's null safety, stable since SDK 2.12, moves these failures from runtime to compile time. This post shows how to migrate a real module without rewriting everything at once.
Why Null Safety Changes the Contract
Before null safety, every type was implicitly nullable. String could be a string or null. After migration, String guarantees a non-null value; the nullable counterpart is String?. The analyzer enforces this distinction, so a call like user.name.length compiles only when user.name is proven non-null. The runtime behavior of compiled code is unchanged—no performance penalty—but you gain a compile-time guarantee that eliminates an entire class of crashes.
Step-by-Step Migration of a User Profile Module
Assume a legacy file lib/profile.dart with no null annotations. The migration tool does the heavy lifting, but you still need to decide intent for each variable.
1. Run the Automated Migration
cd my_project
# Ensure you're on Dart SDK >= 2.12
# dart --version
# Dart SDK version: 3.2.0 (stable)
dart migrate
The tool analyzes the whole package, proposes nullability changes, and opens an interactive web UI. Accept the suggestions for straightforward cases (e.g., fields initialized in constructors). You'll see a diff like:
// Before
class UserProfile {
String displayName;
String? bio;
UserProfile(this.displayName);
}
// After tool suggestion
class UserProfile {
String displayName;
String? bio;
UserProfile(this.displayName);
}
Here displayName stays non-null because the constructor requires it. bio becomes String? since it's optional.
2. Resolve the Remaining Warnings
After applying the tool's changes, run dart analyze. You'll typically see two patterns:
- Implicit nullable parameters:
void greet(String name) { ... }called with aString?variable. Fix by either changing the parameter toString?and handling null, or asserting non-null at the call site with!(only when you're certain). - Dynamic casts:
(json['name'] as String)now warns becausejson['name']isObject?. Replace withjson['name'] as String?followed by a null check, or usejson['name']?.toString()if a fallback is acceptable.
3. Worked Example: Refactoring a Service Method
Legacy method fetching a user's display name:
// Legacy
Future fetchDisplayName(String userId) async {
final data = await http.get(Uri.parse('/users/$userId'));
final json = jsonDecode(data.body);
return json['display_name'] as String; // runtime crash if missing
}
After migration, the analyzer flags the cast. A safe, idiomatic rewrite:
// Null-safe
Future fetchDisplayName(String userId) async {
final response = await http.get(Uri.parse('/users/$userId'));
if (response.statusCode != 200) return null;
final json = jsonDecode(response.body) as Map;
return json['display_name'] as String?; // explicit nullable
}
Callers now see String? and must handle the absent case—exactly the contract you want.
Trade-offs and Limitations
- Third-party packages: If a dependency hasn't migrated, you'll import it with
import 'package:legacy/pkg.dart' as legacy;and interact throughlegacy.ClassName. This adds boilerplate until the package updates. - Hidden bugs surface: Code that previously returned
nullsilently may now require explicit handling. This is a feature, but it can inflate the initial migration effort. - Dynamic-heavy code: Serialization layers using
dynamicorMapneed careful annotation. Consider code generation (e.g.,json_serializable) to keep the boundary typed.
Verify the Migration
- Run
dart analyze— zero errors and no "potential null dereference" warnings. - Execute the test suite:
dart test. Any remaining null-related logic errors will appear as test failures. - Run the application in development mode and exercise the migrated flows. Watch the console for "Null check operator used on a null value" — these indicate a missed
!that the analyzer couldn't catch.
Actionable Closing
Start with one library that has good test coverage. Run dart migrate, accept the obvious changes, then fix the analyzer warnings one file at a time. Commit after each clean dart analyze. Within a few focused sessions you'll have a codebase where null is an explicit design decision, not a surprise.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.