DartExceptor
A lightweight, expressive, and idiomatic Dart result type built on modern Dart 3. No dependencies. No Haskell baggage. Just clean error handling that reads like real code.
Why DartExceptor?
The traditional approach to error handling in Dart looks like this:
try {
final user = await repo.getUser();
print(user.name);
} catch (e) {
print('Something went wrong: $e');
}
This works — until it doesn't. As your app grows, try/catch blocks scatter across your codebase, errors get swallowed silently, and there's no way to tell from a function signature whether it can fail.
DartExceptor solves this by making failure a first-class citizen in your API:
final result = await repo.getUser();
result.split(
data: (user) => print(user.name),
e: (e) => print(e.message),
);
The function signature now tells the full story. The caller is forced to handle both outcomes. No surprises, no silent failures.
Motivation
dartz is powerful, but it carries significant Haskell-inspired complexity that most Flutter developers don't need. DartExceptor is built specifically for Dart and Flutter developers who want:
- A result type that reads like Dart, not Haskell
- Zero runtime dependencies
- Full compatibility with clean architecture patterns
- A minimal, intentional API surface — nothing more, nothing less
Installation
Add to your pubspec.yaml:
dependencies:
dart_exceptor: ^1.0.0
Then run:
dart pub get
Import in your Dart file:
import 'package:dart_exceptor/dart_exceptor.dart';
Core Concepts
Trace<T, E>
Trace<T, E> is the base type. Every operation in DartExceptor returns a Trace.
T— the success typeE— the error type
Trace has exactly two implementations:
Ok<T, E>— represents a successful result, holds a value of typeTErr<T, E>— represents a failed result, holds an error of typeE
You never instantiate Trace directly. You return Ok or Err from your functions
and program against Trace everywhere else.
API Reference
Ok(value) — Wrap a success
return Ok(user);
Err(error) — Wrap a failure
return Err(AppException(code: 500, e: 'Server error'));
split — Handle both outcomes (terminal)
split is your exit point from the Trace world. It consumes the result and
handles both sides. Both handlers are required — you cannot ignore either side.
result.split(
data: (user) => print(user.name), // runs if Ok
e: (e) => print(e.message), // runs if Err
);
split accepts a generic type V — both handlers must return the same type:
final name = result.split<String>(
data: (user) => user.firstName,
e: (e) => 'Unknown',
);
map — Extract and transform the success value
map unwraps the success value from an Ok and applies a transformation.
If called on an Err, it throws — wrap in a try/catch when the result type
is uncertain.
try {
final users = result.map(data: (users) => users);
final activeUsers = users.where((u) => u.isActive).toList();
} catch (e) {
// handle error
}
map returns T directly — not a wrapped Trace. Use it when you are confident
the result is Ok and you want to work with the raw value.
mapError — Extract and transform the error value
mapError is the mirror of map — it unwraps the error from an Err and applies
a transformation. If called on an Ok, it throws.
try {
final users = result.map(data: (u) => u);
print(users.length);
} catch (e) {
final error = result.mapError(e: (e) => e);
print('Error ${error.code}: ${error.message}');
}
This is useful for crossing architectural boundaries — your data layer may produce
a DatabaseException while your domain layer expects an AppException.
bind — Chain operations that return Trace
bind is the most powerful method in DartExceptor. It lets you chain operations that
themselves return a Trace, transforming the success type along the way.
bind accepts a generic type B — the output type of the next operation:
Trace<B, E> bind<B>({required Trace<B, E> Function(T value) n});
In Ok — the function runs, the result is returned:
// Ok runs n(_data) and returns its Trace result
In Err — the function is skipped, the error propagates forward automatically:
// Err skips n entirely and returns Err(_error) as Trace<B, E>
This means if any step in a chain returns Err, all subsequent bind calls
are skipped and the error flows through untouched.
Basic usage
final result = await logic.getAllUsers(); // Trace<List<User>, IException>
final user = result.bind<User>(
n: (users) {
try {
return Ok(users.firstWhere((e) => e.id == id));
} catch (e) {
return Err(IException(code: 404, e: 'User not found'));
}
},
);
Chaining multiple binds
bind returns Trace<B, E>, so you can chain as many as you need —
each one transforming the type further:
final result = await logic.getAllUsers(); // Trace<List<User>, IException>
result
.bind<User>(
n: (users) {
try {
return Ok(users.firstWhere((e) => e.id == id));
} catch (e) {
return Err(IException(code: 404, e: 'User not found'));
}
},
) // Trace<User, IException>
.bind<String>(
n: (user) => Ok(user.firstName),
) // Trace<String, IException>
.split(
data: (name) => print(name),
e: (e) => print(e.e),
);
If getAllUsers() returns Err, both bind calls and split data handler
are skipped — only the e handler in split runs.
Method Summary
| Method | Purpose | Returns |
|---|---|---|
split<V> |
Handle both outcomes, terminal | V |
map |
Extract and transform success value | T |
mapError |
Extract and transform error value | E |
bind<B> |
Chain operations, transform success type | Trace<B, E> |
Real-World Example
A complete clean architecture implementation using DartExceptor:
Define your error type
class AppException {
final int code;
final String e;
final String? stackTrace;
const AppException({
required this.code,
required this.e,
this.stackTrace,
});
}
Data layer
abstract class DataSource {
Future<Trace<List<User>, AppException>> getAllUsers();
Future<Trace<User, AppException>> getUserById({required int id});
}
class RemoteDataSource extends DataSource {
@override
Future<Trace<List<User>, AppException>> getAllUsers() async {
try {
final response = await api.get('/users');
return Ok(response.data.map(User.fromJson).toList());
} catch (e, st) {
return Err(AppException(
code: 500,
e: e.toString(),
stackTrace: st.toString(),
));
}
}
@override
Future<Trace<User, AppException>> getUserById({required int id}) async {
try {
final response = await api.get('/users/$id');
return Ok(User.fromJson(response.data));
} catch (e, st) {
return Err(AppException(code: 404, e: 'User not found'));
}
}
}
Repository layer
abstract class IUserRepository {
Future<Trace<List<User>, AppException>> getAllUsers();
Future<Trace<User, AppException>> getUserById({required int id});
}
class UserRepository extends IUserRepository {
UserRepository({required this.dataSource});
final DataSource dataSource;
@override
Future<Trace<List<User>, AppException>> getAllUsers() => dataSource.getAllUsers();
@override
Future<Trace<User, AppException>> getUserById({required int id}) =>
dataSource.getUserById(id: id);
}
Use case layer
class UserUseCase {
UserUseCase({required this.repository});
final IUserRepository repository;
Future<Trace<List<User>, AppException>> getAllUsers() => repository.getAllUsers();
Future<Trace<User, AppException>> getUserById({required int id}) =>
repository.getUserById(id: id);
}
Presentation layer
// Split :handle both sides
void loadUsers() async {
final result = await useCase.getAllUsers();
result.split(
data: (users) {
print('Total users: ${users.length}');
print('New customers: ${users.where((u) => u.isNewCustomer).length}');
},
e: (e) => print('Failed: ${e.e}'),
);
}
// Map : extract and work with success value
void getSingleUser({required String name}) async {
final result = await useCase.getAllUsers();
try {
final users = result.map(data: (u) => u);
final user = users.firstWhere((u) => u.firstName == name);
print('Found: ${user.firstName} ${user.lastName}');
} catch (e) {
final error = result.mapError(e: (e) => e);
print('Error: ${error.e}');
}
}
// Bind — chain and transform
void getUserById({required int id}) async {
try {
final result = await useCase.getAllUsers();
result
.bind<User>(
n: (users) {
try {
return Ok(users.firstWhere((e) => e.id == id));
} catch (e) {
return Err(AppException(code: 404, e: 'User not found'));
}
},
)
.bind<String>(n: (user) => Ok(user.firstName))
.split(
data: (name) => print('User: $name'),
e: (e) => print('Error: ${e.e}'),
);
} catch (e) {
rethrow;
}
}
Monad Laws
DartExceptor satisfies the three monad laws:
Left identity — wrapping a value in Ok and binding is the same as applying
the function directly:
Ok(value).bind<B>(n: (v) => f(v)) == f(value)
Right identity — binding with Ok preserves the original trace:
trace.bind<T>(n: (v) => Ok(v)) == trace
Associativity — chaining bind calls is associative:
trace.bind<B>(n: f).bind<C>(n: g) == trace.bind<C>(n: (v) => f(v).bind<C>(n: g))
Design Philosophy
One file import. Everything you need comes from a single import.
No runtime dependencies. DartExceptor has zero external dependencies. It will never break because a transitive dependency changed.
Dart 3 native. Built specifically for Dart 3. No legacy workarounds, no compatibility shims.
Architecture agnostic. Works with clean architecture, MVVM, BLoC, or any pattern your team uses. DartExceptor does not impose structure — it just makes error handling honest.
Errors are values. An Err is not an exception. It is a value that describes
what went wrong. It can be passed around, transformed, logged, and handled —
just like any other value.
Type safety through the chain. bind<B> carries type information across every
step — the compiler catches type mismatches before they reach production.
Requirements
- Dart SDK
>=3.0.0 <4.0.0
Contributing
Contributions are welcome. Please open an issue before submitting a PR so we can discuss the change first.
License
MIT License. See LICENSE for details.
Author
Built by Oluwaseyi Fatunmole. My portfolio : https://foluwaseyidev.netlify.app/ hashnode : https://hashnode.com/@foluwaseyi
If DartExceptor saves you from a production bug, consider giving it a ⭐ on GitHub.