Advanced Error Handling in Dart: Type-Safe Failures for Production
This article dives into advanced error handling in Dart, moving beyond basic `try-catch` blocks to build robust, type-safe failure mechanisms. It covers Dart Records for lightweight results, sealed `AppResult` classes with monadic `map` and `flatMap` for enforced handling, integration with `dartz`'s `Either` for functional purity, and Freezed for creating exhaustive, typed exceptions. The goal is to make failures visible, compiler-enforced, and impossible to ignore in production applications.

Every Dart developer has encountered the familiar try-catch block: try { final user = await repository.getUser(id); } catch (e) { print(e.toString()); }. While it works and compiles, this approach often leads to hidden problems in production. Six months down the line, a silent failure might result in a blank screen, requiring hours of debugging to trace back to an undifferentiated catch (e) block.
The fundamental issue with exception-based error handling in Dart is its invisibility. Exceptions don't appear in function signatures, providing no type information at the call site. The compiler cannot assist, as it's unaware that a function might fail. This reliance on a "social contract" between author and caller often breaks down in large teams or during high-pressure incidents.
Production-ready applications demand a more robust solution. This article will guide you through a modern, comprehensive error handling strategy for Dart and Flutter. We'll progress from using Dart Records as simple result containers, to building a sealed Result type, extending it with the Monad pattern, leveraging the dartz package for Either types, and finally, implementing typed, exhaustive exceptions with Freezed. By the end, your application's failures will be typed, visible, compiler-enforced, and impossible to overlook.
The Problem with Exceptions in Dart
Consider a typical layered architecture with exceptions: dart // Repository Future<User> getUser(String id) async { final response = await dio.get('/users/$id'); return User.fromJson(response.data); }
// Use case Future<User> execute(String id) async { return await repository.getUser(id); }
// ViewModel Future<void> loadUser(String id) async { try { final user = await useCase.execute(id); state = UserState.loaded(user); } catch (e) { state = UserState.error(e.toString()); } }
This seemingly reasonable code hides several critical flaws:
- Invisible Failures: The
Future<User>signature promises aUserbut offers no clue about potential network errors, expired tokens, or malformed JSON. The caller must consult the implementation to know about possible failures. - No Compiler Enforcement: Forgetting a
try-catchin theViewModelresults in a runtime crash in production, not a compile-time error. - Catch-All
catch (e): A singlecatch (e)indiscriminately traps everything from typos to genuine network issues, making it impossible to differentiate error types without fragile string inspections. - Type Information Loss: An
UnauthorizedExceptionfrom the API layer degrades to a genericObjectby the time it reaches theViewModel, stripping away all useful structural information.
The solution is to elevate failures to a first-class citizen within your function signatures, type system, and compiler checks.
Part 1: Record Types as Lightweight Result Containers
Dart 3.0 introduced Records: anonymous, immutable value types for grouping multiple fields without requiring a full class. They are structurally typed, immutable, and compare by value.
Records provide a straightforward way to represent success or failure: typedef Result<E, T> = ({E? e, T? data});. This record contract implies that exactly one of e (error) or data (success value) will be non-null. For example:
dart // Success Result<String, User> result = (e: null, data: user); // Failure Result<String, User> result = (e: 'User not found', data: null);
This immediately improves visibility, as the return type now explicitly states that a function can produce either data or an error. You can define specific typedefs for different application layers, like typedef ApiResult<T, E> = ({T? data, E? exception});.
To simplify record creation and enforce consistency, sealed class can be used as a namespace for static factory methods:
dart sealed class Res<E, T> { static Result<E, T> success<E, T>(T data) => (e: null, data: data); static Result<E, T> failure<E, T>(E e) => (e: e, data: null); }
This allows for clean, intentional creation: return Res.success(user); or return Res.failure(iException.internet(message: e.message));. This pattern extends to domain-specific records, making intent clear and self-documenting. However, a limitation is the manual checking of non-null fields, lacking compiler enforcement or built-in transformation mechanisms.
Part 2: Building a Proper Sealed Result Type
To overcome the limitations of records, a sealed class AppResult offers a more robust solution, using Dart's type system to make success and failure structurally distinct and enforcing handling with a when() method.
dart import 'app_failure.dart';
sealed class AppResult<T> { const AppResult();
R when<R>({ required R Function(T value) success, required R Function(AppFailure failure) failure, }); }
class AppSuccess<T> extends AppResult<T> { const AppSuccess(this.value); final T value; // ... when implementation ... }
class AppFailureResult<T> extends AppResult<T> { const AppFailureResult(this.error); final AppFailure error; // ... when implementation ... }
AppResult<T> is sealed, meaning all its subtypes (AppSuccess<T>, AppFailureResult<T>) must be in the same file, enabling exhaustive pattern matching. AppSuccess holds the successful value, while AppFailureResult holds an AppFailure (your custom error model). The when() method is crucial: it requires both a success and a failure callback. The compiler will not allow you to forget either path, guaranteeing comprehensive error handling at the call site. The object itself dictates which branch executes, not manual if/else statements.
Consuming AppResult is clean and enforced:
dart final result = await _repository.login(email, password); result.when( success: (user) => emit(AuthState.authenticated(user)), failure: (error) => emit(AuthState.error(error.message)), );
The when() method's return type R is inferred from what both callbacks return, allowing you to easily produce a Widget, a String, or any other type.
This approach significantly improves upon exceptions:
| Feature | Exceptions | AppResult | Comment |
|---|---|---|---|
| Failure visible in signature | ❌ | ✅ | Clear return type indicates failure |
| Compiler enforces handling | ❌ | ✅ | when() method requires all cases |
| Both paths required | ❌ | ✅ | Forces complete handling |
| Type safe across all layers | ❌ | ✅ | Preserves error type information |
| Readable and self-documenting | ❌ | ✅ | Intent is explicit at a glance |
Part 3: Extending to the Monad Pattern
For AppResult to be fully monadic, it needs map and flatMap methods. A type is monadic if it supports:
- Wrap: Placing a value into the context (e.g.,
AppSuccess(user)). - Transform (
map): Applying a function to the wrapped value without manual unwrapping. If it's a failure, the transformation is skipped, and the failure propagates. - Chain (
flatMap): Sequencing multiple operations that each return the same wrapper type. The first failure short-circuits the entire chain.
Adding map and flatMap to AppResult:
dart sealed class AppResult<T> { // ... existing code ...
AppResult<R> map<R>(R Function(T value) transform) { return when( success: (value) => AppSuccess(transform(value)), failure: (error) => AppFailureResult(error), ); }
AppResult<R> flatMap<R>(AppResult<R> Function(T value) transform) { return when( success: (value) => transform(value), failure: (error) => AppFailureResult(error), ); }
// ... when method ... }
map transforms the success value, propagating failures untouched. flatMap enables chaining operations where each step can itself return an AppResult. This allows for sequential execution where the first failure immediately stops the chain, eliminating deeply nested when() calls:
dart // Before flatMap: Deeply nested when() calls // final loginResult = await login(email, password); // loginResult.when( success: (...) profileResult.when(...) ... );
// With flatMap: Clean, sequential chaining final result = (await login(email, password)) .flatMap((user) => getProfile(user.id)) .flatMap((profile) => loadSettings(profile.settingsId)) .map((settings) => settings.theme);
result.when( success: (theme) => emit(AppState.ready(theme)), failure: (error) => emit(AppState.error(error)), );
This pattern centralizes error handling at the end of the chain, making complex sequences of operations far more readable and manageable.
Part 4: Either with dartz
The dartz package provides Either<L, R>, a functional programming type representing one of two possible values: Left (typically failure) or Right (typically success). However, in the source content's context, the convention is often flipped: typedef API<T> = Either<T, iException>;, where Left holds the success value T, and Right holds the failure iException. Consistency is key here.
Creating Either values is straightforward:
dart // Success (Left holds data) Either<User, iException> result = Left(user); // Failure (Right holds exception) Either<User, iException> result = Right(iException.internet(message: 'No connection'));
A crucial part of integrating Either into a layered architecture is bridging between ApiResult records (from the data layer) and Either (for the domain layer). A utility class like ApiRes handles this conversion:
dart class ApiRes { static Future<API<T>> deserialize<T>(ApiResult<T, iException> res) async { return (res.data != null) ? Left(res.data as T) : Right(res.exception!); } // ... other deserialize methods ... }
This allows repository methods to return Either by converting the record-based data source response:
dart Future<API<User>> getUser(String id) async { final res = await _dataSource.fetchUser(id); // Data layer returns a record return ApiRes.deserialize<User>(res); // Convert to Either at the boundary }
dartz also provides a fold method, similar to AppResult's when(), to handle both success and failure paths:
dart result.fold( (user) => emit(UserState.loaded(user)), // Left (success) (exception) => emit(UserState.error(exception.message)), // Right (failure) );
Like AppResult, dartz's Either comes with monadic operations like map and flatMap (bind) out of the box, offering a complete functional toolkit for chaining and transforming results.
Part 5: Typed Exceptions with Freezed
Standard Dart exceptions (throw Exception('Something went wrong');) are vague, lacking useful type information for handling. Freezed solves this by generating robust, immutable, and pattern-matchable exception classes with minimal boilerplate.
By annotating a class with @freezed and defining named constructors, Freezed generates a sealed base class and its concrete subtypes, along with ==, hashCode, toString, and copyWith methods automatically.
dart import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'exception.freezed.dart';
@freezed class iException with $iException { const factory iException.internet({required String message, int? code,}) = InternetException; const factory iException.mapper({required String message, int? code,}) = MapperException; const factory iException.validation({required String message, int? code,}) = ValidationException; const factory iException.unauthorized({required String message, int? code,}) = UnauthorizedException; const factory iException.unknown({required String message, int? code,}) = UnknownException; const iException.(); // Required for instance methods/getters }
After running flutter pub run build_runner build, Freezed creates a sealed iException with specific subtypes like InternetException, MapperException, etc. This allows for explicit and clean exception creation, such as iException.internet(message: 'No internet connection').
Because iException is a Freezed sealed class, you automatically gain when(), maybeWhen(), map(), and maybeMap() for exhaustive pattern matching on exception types:
dart exception.when( internet: (message, code) => 'No internet: $message', mapper: (message, code) => 'Parse error: $message', validation: (message, code) => 'Invalid input: $message', unauthorized: (message, code) => 'Unauthorised — please log in again', unknown: (message, code) => 'Unexpected error: $message', );
This ensures every possible exception type is considered, preventing runtime surprises and dramatically improving code clarity and maintainability. It transforms vague errors into strongly typed, actionable failures.
Part 6: Putting It All Together
The full architecture integrates these patterns across your application layers:
- Repository Layer: Uses
ApiResultrecords for raw API responses. Converts these records intoEither<T, iException>usingApiRes.deserializebefore returning them. - Domain Layer (Use Cases): Primarily works with
Either<T, iException>. It leveragesmapandflatMap(orbind) for chaining operations andfoldfor handling final results. It also usesiExceptionfor all failure types. - Presentation Layer (ViewModels/Blocs/Providers): Consumes
Either<T, iException>orAppResult<T>. It usesfold()orwhen()to gracefully handle success and failure states, dispatching appropriate UI updates based on the typediException.
This structured approach ensures that errors are never lost, always typed, and explicitly handled at every significant boundary, leading to far more robust and debuggable applications.
Conclusion
Moving beyond simple try-catch blocks is crucial for building production-grade Dart applications. By embracing Dart Records, sealed Result types (with monadic map and flatMap), dartz's Either, and Freezed for typed exceptions, you transform error handling from an afterthought into an integral, type-safe part of your application's design. Failures become visible in function signatures, compiler-enforced, and impossible to ignore, leading to more stable, maintainable, and predictable software.
FAQ
Q: Why use both AppResult (sealed class with when()) and Either (dartz)? Aren't they similar?
A: While both serve similar purposes in representing success/failure and providing exhaustive handling (when() vs fold()), they can coexist or be chosen based on preference. AppResult is a custom implementation, offering full control over its behavior and direct integration with your custom AppFailure type. Either from dartz is a pre-built, battle-tested functional programming primitive that comes with a rich set of monadic utilities (map, flatMap, etc.) out of the box. Some teams might prefer dartz for its functional purity and feature set, while others might build a custom AppResult for tighter coupling with their specific error hierarchy. In this article's context, ApiRes provides a bridge, allowing Either to be used in the domain layer while records handle the data layer.
Q: What is the performance overhead of using Records, Sealed classes, and Freezed compared to simple exceptions?
A: The performance overhead for these patterns is generally negligible for typical application use cases. Records are highly optimized value types. Sealed classes and their subtypes are regular Dart objects, and their when() or fold() methods are efficient branching operations. Freezed primarily generates boilerplate code at compile time; the runtime performance of the generated classes is comparable to manually written immutable classes with proper == and hashCode implementations. The benefits in terms of type safety, maintainability, and reduced debugging time far outweigh any minor, practically unnoticeable runtime overhead in most Flutter applications.
Q: How does this approach handle asynchronous operations and streams?
A: This approach integrates seamlessly with asynchronous operations. As shown in the examples, Future<AppResult<T>> or Future<Either<T, iException>> are the primary return types for async functions. flatMap is particularly powerful for chaining asynchronous operations that each return a Future of a Result or Either. For streams, you would typically transform a Stream<T> into a Stream<AppResult<T>> or Stream<Either<T, iException>>, allowing each emitted event to carry its success or failure state. This maintains the same principles of explicit, typed error handling across your reactive data flows.
Related articles
Build Your Own Local NMT App with React Native and QVAC
This article explores how Neural Machine Translation (NMT), powered by the Transformer architecture, revolutionized translation by understanding context. We then delve into QVAC, a local-first AI development platform, and its Bergamot engine, enabling private, on-device translation. Learn to set up a React Native app with QVAC and manage model lifecycles for efficient local translation.
Unpacking Roman Concrete's Durability: Carbonation and Self-Healing
The Enduring Legacy: Roman Concrete's Millennia-Long Stand As software developers, we're familiar with the ephemeral nature of technology; systems evolve, frameworks deprecate, and codebases undergo constant
PayPal in Microservices: NestJS, gRPC, and Docker Blueprint
Integrating payment logic directly into every microservice within a distributed system often leads to significant challenges. Scattering PayPal API calls across services like user-service, order-service, or
Demystifying Dijkstra's Algorithm: The Shortest Path Pioneer
Explore Dijkstra's Algorithm, the foundational pathfinding technique conceived by Edsger W. Dijkstra. This guide explains how it solves shortest path problems using graphs, nodes, edges, and weights. Learn its greedy approach and the critical role of data structures like adjacency lists and priority queues in its efficient Python implementation.
AWS Leadership Shift: What It Means for Compute and AI/ML
Dave Brown, a key figure in AWS's EC2 and AI/ML growth, is departing. His successor, Dave Treadwell, brings extensive experience from Microsoft and Amazon's eCommerce Foundation, potentially signaling new directions for core cloud services and AI innovation.
Is Your Smart Fridge a Scraper? New Data Uncovers Hidden Botnets
New data from Anubis' honeypot reveals a pervasive scraping problem, with nearly 90% of observed scraper IPs not on traditional threat lists. This global phenomenon is likely driven by compromised smart appliances, highlighting a hidden botnet threat. The findings underscore the need for advanced WAFs and user vigilance in securing IoT devices.




