Stop Returning null on Failure: Model Dart Domain States with Sealed Classes
Dart 3 sealed classes replace null-sentinel returns and forgotten error branches with compiler-checked domain models. A worked example shows exhaustive switching over a profile-fetch result type.
28 Sept 2025, 12:16 UTC

Every Dart codebase has a function like this: it fetches a user profile and returns User?. On failure it returns null — the same value it returns when the user simply doesn't exist. The caller can't distinguish "network died" from "no such user," so it shows a generic error and moves on. The information was there; the return type threw it away.
Dart 3.0's sealed classes fix this cleanly. They let you model a closed set of outcomes — success, not-found, network error, validation error — as real types with real fields, and the compiler guarantees you handle every one of them. No sentinel values, no forgotten branches, no runtime surprises.
What "sealed" actually buys you
A sealed class can only be extended or implemented inside the same library (the same file, or files joined with part/part of). Because the compiler knows the complete list of subtypes, it can check that a switch expression covers all of them. Miss one, and dart analyze fails before your code ever runs.
This is the key difference from a plain abstract class: with an open hierarchy, the compiler can't know every subtype, so it forces a default branch that silently swallows new cases forever. With a sealed hierarchy, adding a new variant turns every incomplete switch in your codebase into a compile error — a guided tour of every place you need to think about the new case.
Compared to enums, sealed classes carry data. Each variant can have its own fields, generics, and behavior, which is exactly what domain modeling needs.
A worked example: a fetch result type
Here's a realistic hierarchy for a profile lookup, written against Dart 3.2+:
sealed class ProfileResult {}
class Found extends ProfileResult {
Found(this.user);
final User user;
}
class NotFound extends ProfileResult {}
class Offline extends ProfileResult {
Offline({required this.cachedAt});
final DateTime cachedAt;
}
class Failure extends ProfileResult {
Failure(this.message, {this.statusCode});
final String message;
final int? statusCode;
}Now the UI layer consumes it with a switch expression, destructuring fields directly in the case clauses:
String describe(ProfileResult result) => switch (result) {
Found(user: final u) => 'Hello, ${u.name}',
NotFound() => 'No profile exists for this ID.',
Offline(cachedAt: final t) => 'Offline. Last synced $t.',
Failure(message: final m, statusCode: final c) =>
'Error ${c ?? "—"}: $m',
};Delete the Offline case and the analyzer reports a non-exhaustive switch. That check is the whole point: the type system now enforces what code reviews used to catch (or miss).
To verify this yourself: create a scratch project with dart create -t console sealed_demo (requires a Dart 3.x SDK — check with dart --version), paste in the hierarchy, and run dart analyze with a case removed. You should see an exhaustiveness error naming the missing subtype. Then run dart run with sample instances to confirm the destructured values land where you expect.
Patterns beyond the simple cases
Switch expressions compose with the rest of Dart 3's pattern features. A few that earn their keep with sealed hierarchies:
- Guard clauses refine a case without nesting:
Failure(statusCode: final c) when c != null && c >= 500 => retry(result)separates server errors from client errors while staying exhaustive. - Nested destructuring reaches into records and collections: if
Foundheld a record of related users,Found(related: [first, ...])pulls the first match inline. - if-case handles the one-variant fast path:
if (result case Found(user: final u)) { cache(u); }avoids a full switch when only one shape matters.
One subtlety: exhaustiveness checking applies to switch expressions and, since Dart 3, also to switch statements over sealed types. But the expression form is the better default — it forces a value for every branch, which makes the "what do we show the user" question impossible to skip.
The trade-offs worth knowing upfront
Closed means closed. Subtypes must live in one library. If your architecture depends on plugins or third parties extending your types, sealed classes are the wrong tool — use a regular interface and accept the default branch. Trying to split a sealed hierarchy across packages produces compile errors, not warnings.
New variants are breaking changes. If you publish a package exposing a sealed type, adding a subclass breaks every downstream exhaustive switch. That's arguably a feature — consumers should decide how to handle the new case — but treat it as a major-version change and call it out in your changelog. During a migration window, a temporary default case can soften the landing, at the cost of losing the exhaustiveness guarantee until it's removed.
Nullability is separate. A ProfileResult variable is non-nullable; ProfileResult? adds null as a case the switch must also handle. Decide deliberately whether "no result yet" is a null or an explicit Loading variant — the latter usually reads better in UI state machines.
Compile time. Very large hierarchies with deeply nested patterns can slow analysis. In practice this rarely matters at the scale of a typical app, but if dart analyze gets noticeably slower after adopting heavy pattern matching, flatten the patterns before reaching for anything exotic.
Where to start
Pick one place in your codebase where a function currently returns null, throws for expected outcomes, or returns a boolean plus an out-parameter-style field. Convert its outcomes to a sealed hierarchy of two or three variants, switch over it at the call site, and let dart analyze walk you through every consumer. The first time the compiler catches a case you forgot — before your users do — the pattern sells itself.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.