dart_exceptor 1.1.1 copy "dart_exceptor: ^1.1.1" to clipboard
dart_exceptor: ^1.1.1 copied to clipboard

A lightweight, idiomatic Dart result type. Built on Dart 3 with zero dependencies. Features Trace<T,E>, Ok, Err, split, map, mapError and bind.

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 type
  • E — the error type

Trace has exactly two implementations:

  • Ok<T, E> — represents a successful result, holds a value of type T
  • Err<T, E> — represents a failed result, holds an error of type E

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.

4
likes
160
points
146
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A lightweight, idiomatic Dart result type. Built on Dart 3 with zero dependencies. Features Trace<T,E>, Ok, Err, split, map, mapError and bind.

Repository (GitHub)
View/report issues

License

MIT (license)

More

Packages that depend on dart_exceptor