Dart’s Sound Null Safety: Lessons from Kotlin, Applied to Flutter’s Language
How a nullable-by-default language retrofitted type-system guarantees onto millions of lines of existing code — and what it learned from every language that tried this before.
Every language that cares about correctness eventually comes for null. Tony Hoare called it his “billion-dollar mistake” when he introduced null references in ALGOL W in 1965 — and for sixty years since, nearly every mainstream language has either inherited that mistake wholesale or spent enormous effort designing around it.
Dart’s path was typical of the first kind, then exceptional in the second. Launched by Google in 2011 as a JavaScript replacement, Dart started nullable-by-default: any variable could be null at any time, the compiler had nothing to say about it, and runtime Null check operator used on a null value crashes were the inevitable tax. When Flutter adopted Dart in 2017 and the ecosystem exploded, the problem compounded — millions of lines of code built on a shaky foundation.
Then in March 2021, with Dart 2.12, the language shipped sound null safety. Not “null safety with exceptions” or “null safety if you feel like it” — sound null safety, meaning the compiler guarantees that if your code compiles cleanly, no non-nullable variable will ever be null at runtime. No asterisks. That’s a strong claim, and how Dart got there is worth understanding in full.
1. What “Sound” Actually Means
The word “sound” is doing serious work here. In type theory, a type system is sound if you can trust its static guarantees at runtime — i.e., if the compiler says a value is a non-nullable String, it is never, ever null when your code runs. Unsound type systems give you warnings or hints but don’t actually back them up.
Dart’s null safety is sound because it’s enforced at the type system level, not via runtime checks. Non-nullable types and nullable types (String vs String?) are genuinely distinct types. You cannot assign a nullable to a non-nullable without explicitly handling the null case. Flow analysis means the compiler tracks where you’ve already null-checked a variable and upgrades its type accordingly — no redundant checks needed.
Type theory context
TypeScript’s type system, for comparison, is intentionally unsound — it allows escape hatches like
any, type assertions that override the compiler, and structural subtyping rules that permit certain unsafe assignments. This is a pragmatic choice for adoption, but it means TypeScript’s null checks are warnings, not guarantees. Dart chose the harder, sounder path.
2. How Dart Compares to Kotlin and Swift
Dart didn’t invent nullable/non-nullable type distinctions. Kotlin shipped null safety as a core feature in 2016. Swift had Optionals from version 1.0 in 2014. The approaches differ meaningfully:
The surface syntax is near-identical — the real differences are in philosophy and the weight of legacy. Kotlin had to live peacefully with the Java ecosystem, where null is everywhere and platform types (T! — “this might be null, figure it out”) are the uneasy truce. Swift designed Optionals into Objective-C’s nullability annotations so the bridge would be clean. Dart had a different problem: a large Flutter ecosystem that was already completely written without null safety and needed to migrate.
| Dimension | Dart 2.12+ | Kotlin 1.0+ | Swift 1.0+ |
|---|---|---|---|
| Null safety from day one? | No — retrofitted | Yes | Yes |
| Type system soundness | Sound | Mostly sound (platform types) | Sound |
| Interop with null-unsafe code | Mixed mode (migration period) | Platform types bridge Java | Nullability annotations for ObjC |
| Flow analysis / smart casts | Yes — full flow analysis | Yes — smart cast | Yes — if let / guard let |
| Unsafe escape hatch | ! (null assertion) | !! (non-null assertion) | ! (forced unwrap) |
| Deferred init escape | late keyword | lateinit | ImplicitlyUnwrappedOptional (!) |
| Migration tooling provided | dart migrate CLI tool | Manual (fresh language) | Manual (fresh language) |
One asymmetry stands out: Dart shipped a dedicated migration tool. Kotlin and Swift didn’t need to — their null safety was baked in from launch. Dart was the first major language to attempt a sound null safety retrofit at ecosystem scale, which made the migration tooling question genuinely novel.
3. The Migration: dart migrate and How It Worked
The Dart team’s approach to migration was unusually thoughtful. Rather than a hard cutover — “rewrite everything or it doesn’t compile” — they introduced a mixed-mode system where null-safe packages and legacy packages could coexist during a transition period. Every package declared its null safety status in pubspec.yaml via the SDK constraint. The package ecosystem migrated library by library, bottom-up from low-level dependencies.
Dart 2.9 — 2020
Experimental preview — null safety available behind a flag. Early adopters filed bugs, the type inference engine was refined, edge cases in flow analysis were fixed.
Dart 2.12 — Mar 2021
Stable release. The Dart SDK and core Flutter libraries shipped as null-safe. The dart migrate CLI tool went stable. Mixed-mode interop kept the ecosystem from breaking overnight.
Flutter 2.0 — Mar 2021
Flutter goes null-safe. New projects generated null-safe code by default. Migration guide published. The broader pub.dev ecosystem began rapid adoption.
Dart 3.0 — May 2023
Legacy code dropped. Non-null-safe packages became incompatible with Dart 3. The migration period officially closed. Sound null safety is now the only mode.
The dart migrate tool itself deserves credit. It ran static analysis on your entire codebase, inferred where nullability annotations should go based on usage patterns, and presented an interactive web UI where you could review suggested changes file by file, accept or override them, and re-run analysis. It wasn’t magic — it couldn’t know your domain intent — but it eliminated the most mechanical parts of the work and correctly annotated the majority of straightforward cases.
# Run the null safety migration tool interactively # Requires Dart SDK 2.12–2.x (tool removed in Dart 3) dart migrate # Or apply migration and apply changes without interactive UI dart migrate --apply-changes # After migration: verify the package is fully null-safe dart pub publish --dry-run
Note on dart migrate
The
dart migratecommand was only available during the transition window (Dart 2.x). As of Dart 3.0, non-null-safe code simply won’t compile. If you’re on a legacy codebase still using it today, the official migration guide is the current reference.
4. The late Keyword Controversy
No discussion of Dart’s null safety is complete without addressing late — the most debated keyword to ship in the Dart language. It’s the escape hatch for deferred initialization: you declare a non-nullable variable, promise the compiler you’ll assign it before anyone reads it, and ask for its trust.
// Without late — compiler demands immediate initialization String name; // Error: must be initialized // With late — compiler trusts you to assign before access late String name; name = fetchUserName(); // assigned before first read — ok // late also enables lazy initialization (computed on first access) late final String expensiveValue = computeExpensiveValue(); // computeExpensiveValue() only runs when expensiveValue is first read
The controversy is that late punches a hole in the type system’s guarantee — if you read a late variable before it’s been assigned, you get a runtime error, not a compile-time one. Critics argued this was essentially opt-in unsafety disguised as a language feature.
Defenders pointed out that Kotlin has exactly the same mechanism in lateinit, and that the alternative — forcing nullable types everywhere dependency injection or framework lifecycle code is involved — would make Flutter development genuinely miserable.
The
latekeyword is a deliberate escape valve. A type system that’s too strict to be usable is no type system at all — it just gets bypassed everywhere.— Erik Ernst, Dart language team, Dart Language Specification discussions
In practice the debate settled into a community norm: late is acceptable for framework-controlled lifecycle (Flutter’s initState patterns, dependency injection, test setup), and a code smell anywhere else. The late final form — lazy initialization that can only be assigned once — is widely considered the cleanest use of the feature.
5. Ecosystem Adoption: The Numbers
Null-Safe Package Adoption on pub.dev (Top 500 Packages). Share of the top 500 most-downloaded Dart packages that had migrated to null safety, by quarter

Crash Rate Reduction: Flutter Apps Before and After Null Safety Migration
Null-dereference crashes as a share of total reported Flutter crashes — normalized across a sample of apps that completed migration

The adoption curve was steep because the Flutter ecosystem had strong centralization — a relatively small number of heavily-used packages on pub.dev accounted for the majority of dependencies. When those packages migrated, their downstream users were able to follow. By the time Dart 3.0 closed the door on legacy code in May 2023, the migration was effectively complete across the active ecosystem.
6. Can You Retrofit Safety Into an Existing Ecosystem?
This is the real lesson — and the answer is a qualified yes, but with conditions that are harder than they look.
| Condition | Dart’s Situation | Why It Mattered |
|---|---|---|
| Controlled runtime | Single owned VM/compiler | No JVM null interop problem. Google could enforce soundness end-to-end. |
| Ecosystem centralization | pub.dev + Flutter team leading | Core packages migrated by Google. Downstream followed naturally. |
| Clear deprecation timeline | 2-year transition, then hard cutoff | Teams couldn’t defer indefinitely. Dart 3.0 forced the issue. |
| Automated migration tooling | dart migrate CLI + web UI | Removed mechanical work. Teams with large codebases could migrate in days, not weeks. |
| Community buy-in | Significant initial resistance | The migration period was contentious. Flutter devs with large apps pushed back on the timeline. |
Compare this to JavaScript, which is attempting something vaguely similar via TypeScript adoption — except TypeScript can’t enforce soundness at the engine level, the ecosystem is orders of magnitude larger, and there’s no Google-controlled platform to mandate migration on. The result is a perpetual opt-in, which means perpetual partial safety. Dart’s advantage was that Flutter gave the language team leverage: if you want your Flutter app to keep building, you migrate. Not every language has that lever.
The deeper lesson is that retrofitting type safety is ultimately a social and organizational problem as much as a technical one. The type theory was solvable. Building the tooling was hard but finite. Getting an entire developer community to invest weeks of migration work — against real deadlines, on real products — required trust, clear communication, a credible deadline, and a migration experience that didn’t feel like punishment.
7. What We’ve Learned
Dart’s sound null safety migration is one of the most instructive case studies in modern programming language evolution. We traced how Dart went from nullable-by-default to a genuinely sound type system — not as a language redesign, but as a retrofit onto a live, production ecosystem with millions of Flutter developers depending on it. We compared the approach against Kotlin’s lateinit and Swift’s Optionals, both of which had the luxury of starting fresh.
We examined the mixed-mode interop strategy, the dart migrate tooling, the hard cutoff in Dart 3.0, and the real adoption numbers on pub.dev. We unpacked the late keyword — both the legitimate use cases and the risks — and drew the honest conclusion: it’s a necessary escape valve that Kotlin made too, not a design flaw. The final question — can you retrofit safety into an existing ecosystem? — gets a yes, but only when you control the runtime, centralize the ecosystem, provide real tooling, and set a credible deadline.
Without all four, you get TypeScript: useful, widely adopted, and permanently, structurally unsound.




