The Result Pattern with Sealed Classes
try/catch around every repository call hid a bug for weeks: one code path caught Exception, swallowed it, and returned null. The UI showed an empty state that looked like "no data" instead of "request failed." Sealed Result types force the caller to acknowledge failure branches at compile time—exhaustive switches do not let you forget.
Defining a sealed Result
sealed class Result<T, E> {
const Result();
}
final class Success<T, E> extends Result<T, E> {
const Success(this.value);
final T value;
}
final class Failure<T, E> extends Result<T, E> {
const Failure(this.error);
final E error;
}
Use a sealed error type too:
sealed class ApiError {
const ApiError();
}
final class NetworkError extends ApiError {
const NetworkError(this.message);
final String message;
}
final class Unauthorized extends ApiError {
const Unauthorized();
}
Repository returning Result
class UserRepository {
Future<Result<User, ApiError>> fetchUser(String id) async {
try {
final response = await _client.get('/users/$id');
if (response.statusCode == 401) {
return const Failure(Unauthorized());
}
if (response.statusCode != 200) {
return Failure(NetworkError('HTTP ${response.statusCode}'));
}
return Success(User.fromJson(response.data));
} on SocketException {
return const Failure(NetworkError('offline'));
}
}
}
No throw across the domain boundary—failures are data.
Exhaustive handling
Future<void> load() async {
final result = await repo.fetchUser(id);
switch (result) {
case Success(value: final user):
state = AsyncData(user);
case Failure(error: final Unauthorized()):
auth.logout();
case Failure(error: final NetworkError(:final message)):
state = AsyncError(message);
}
}
Dart 3 exhaustiveness checking warns if you add a new ApiError variant without updating switches—refactors stay safe.
Mapping and chaining
Result<UserDto, ApiError> mapUser(Result<JsonMap, ApiError> raw) {
return switch (raw) {
Success(value: final json) => Success(UserDto.fromJson(json)),
Failure(error: final e) => Failure(e),
};
}
For heavy chaining, extension methods help:
extension ResultX<T, E> on Result<T, E> {
Result<R, E> mapSuccess<R>(R Function(T) f) => switch (this) {
Success(value: final v) => Success(f(v)),
Failure(error: final e) => Failure(e),
};
}
UI presentation
Widget buildBody(Result<List<Item>, ApiError> result) {
return switch (result) {
Success(value: final items) when items.isEmpty =>
const EmptyState(),
Success(value: final items) =>
ItemList(items: items),
Failure(error: final NetworkError()) =>
const OfflineBanner(),
Failure(error: final Unauthorized()) =>
const LoginPrompt(),
};
}
Pattern guards keep widgets declarative—no nested if-is checks.
Testing advantages
test('returns Failure on 401', () async {
when(() => client.get(any())).thenAnswer(
(_) async => Response(statusCode: 401, data: null),
);
final result = await repo.fetchUser('1');
expect(result, isA<Failure<User, ApiError>>());
expect((result as Failure).error, isA<Unauthorized>());
});
Assertions on types beat expecting thrown exceptions.
When not to use Result
- Flutter framework callbacks where exceptions are idiomatic
- Parser bugs and assert-level invariants—keep throws
- Interop with APIs that only throw unless you wrap at the boundary once
Use Result at architectural seams: repositories, use cases, platform services.
Interop at system boundaries
Wrap third-party SDKs that throw at your repository boundary:
Future<Result<Payment, PaymentError>> charge(PaymentRequest req) async {
try {
final receipt = await stripe.charge(req);
return Success(receipt);
} on StripeException catch (e) {
return Failure(PaymentError.fromStripe(e));
}
}
UI never imports Stripe types—only Result and your sealed PaymentError.
Logging and observability
Log failures with structured fields:
case Failure(error: final NetworkError(:final message)):
logger.warning('fetch failed', extra: {'message': message});
Do not log Success payloads containing PII at info level in production.
Migration from nullable returns
Legacy Future<User?> where null meant not found:
@Deprecated('Use fetchUserResult')
Future<User?> fetchUser(String id) async {
final r = await fetchUserResult(id);
return switch (r) {
Success(value: final u) => u,
Failure() => null,
};
}
Migrate callers incrementally; delete deprecated path when grep clean.
Serialization across isolates
Result types crossing isolates must be sendable—sealed errors with simple fields work; avoid wrapping non-Sendable objects in Failure.
GraphQL and Result
Map GraphQL errors array to Failure variants—partial data with errors becomes Success with warnings or Failure based on product rules documented per query.
Documentation for consumers
README on repository package lists all Error variants and when thrown—onboarding speed for new engineers.
Lint enforcement
Custom lint: forbid throw in lib/data/ layer except documented exceptions—CI grep check until analyzer plugin exists.
HTTP status mapping table
Maintain table in repo docs: 401 → Unauthorized, 403 → Forbidden, 404 → NotFound sealed variants—repository layer maps consistently; UI switches on sealed types not raw status integers.
Rollout guidance
Result pattern adoption starts single bounded context checkout Q1 expand orders Q2—big bang repository rewrite abandoned mid-sprint teaches incremental migration lesson.
Team practices
Shipping Flutter Result Pattern Sealed in production taught our team that documentation beats hero demos. We wrote runbooks covering failure modes, on-call steps, and rollback triggers before the second release, not after the first outage.
When reviewing PRs touching Flutter Result Pattern Sealed, we ask for measurable outcomes: latency, crash rate, or support ticket volume—not subjective feels faster. If metrics are unavailable, the PR includes a DevTools trace or load test snippet proving the change.
Onboarding engineers paste a checklist into their first Flutter Result Pattern Sealed PR description: environment setup verified, tests added or updated, accessibility spot-checked, and release notes drafted for user-visible changes.
We keep a living FAQ in the repo wiki for Flutter Result Pattern Sealed questions repeated in Slack more than twice. FAQ entries link to source files and Slack threads, reducing tribal knowledge and repeated explanations in code review.
Resources
- Dart sealed classes
- Pattern matching (Dart docs)
- Exhaustive switch checking
- fpdart TaskEither
- Effective Dart: Design
Frequently asked questions
Result pattern vs throwing exceptions in Dart?
Exceptions are fine for truly exceptional programmer errors. Result types model expected failures—network timeouts, validation errors, 404 responses—as values callers must handle. That makes control flow visible in types and tests without try/catch gymnastics.
Do I need a package or roll my own Result?
Dart 3 sealed classes make a lightweight custom Result trivial and zero-dependency. Packages like multiple_result or fpdart add map/flatMap utilities if you want functional chaining. Start sealed and add a package only when boilerplate grows.
How does Result work with async UI state?
Map Result to AsyncValue or your own loading/error/data union in ViewModels. The repository returns Result<User, ApiError>; the notifier sets state based on switch (result) { Success() => ..., Failure() => ... }.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →