verdict 1.1.0 copy "verdict: ^1.1.0" to clipboard
verdict: ^1.1.0 copied to clipboard

A sealed Result type and a structured Failure hierarchy for Dart, so calls return a typed verdict instead of throwing across layer boundaries.

example/verdict_example.dart

import 'package:verdict/verdict.dart';

/// A stand-in for whatever HTTP client you actually use.
class HttpTimeoutException implements Exception {
  const HttpTimeoutException(this.seconds);

  final int seconds;
}

/// A stand-in for a server that answered, but said no.
class HttpStatusException implements Exception {
  const HttpStatusException({required this.status, required this.body});

  final int status;
  final String body;
}

/// One mapper per error source keeps SDK-specific imports out of the rest of
/// the app. Each declines what it does not recognise by returning `null`.
class HttpFailureMapper implements ChainedFailureMapper {
  const HttpFailureMapper();

  @override
  Failure? tryMap(Object error, [StackTrace? stackTrace]) {
    // Pass the error's own trace so the origin names where it was thrown,
    // not where it was caught.
    final origin = failureOrigin(stackTrace: stackTrace);
    return switch (error) {
      HttpTimeoutException(:final seconds) => NetworkFailure(
          title: origin,
          message: 'The server did not answer within ${seconds}s.',
        ),
      HttpStatusException(:final status, :final body) when status == 401 =>
        AuthFailure(title: origin, message: body),
      HttpStatusException(:final status, :final body) => ApiFailure(
          title: origin,
          message: body,
          code: status,
        ),
      _ => null,
    };
  }
}

const mapper = CompositeFailureMapper(
  [HttpFailureMapper()],
  fallback: DefaultFailureMapper(),
);

class User {
  const User(this.name);

  final String name;
}

/// A repository catches once, at the boundary, and returns a [Result].
class UserRepository {
  Future<Result<User>> getUser(String id) =>
      guard(() async => _fetch(id), mapper);

  Future<Result<Unit>> logout() => guard(() async {
        // …clear the session…
        return unit;
      }, mapper);

  /// A second fallible call, to show a chain of them.
  Future<Result<String>> getGreeting(String id) =>
      guard(() async => 'welcome, ${(await _fetch(id)).name}', mapper);

  Future<User> _fetch(String id) async {
    if (id == 'slow') throw const HttpTimeoutException(30);
    if (id == 'gone') {
      throw const HttpStatusException(status: 404, body: 'No such user');
    }
    if (id == 'stale') {
      throw const HttpStatusException(status: 401, body: 'Session expired');
    }
    return User('Ada');
  }
}

Future<void> main() async {
  final repository = UserRepository();

  for (final id in ['ada', 'slow', 'gone', 'stale']) {
    final result = await repository.getUser(id);

    // The switch is exhaustive — no default branch, no forgotten error path.
    switch (result) {
      case Ok(:final value):
        print('$id -> welcome, ${value.name}');
      case Err(:final failure):
        print('$id -> ${_describe(failure)}');
    }
  }

  // `fold` collapses both branches when you just want one value out.
  final label = (await repository.getUser('ada')).fold(
    onOk: (user) => user.name,
    onErr: (failure) => failure.message,
  );
  print('label: $label');

  // Operations with nothing to return use Result<Unit>.
  print('logout ok: ${(await repository.logout()).isOk}');

  // Chaining happens on the future itself, so the pipeline reads forwards
  // and the first failure short-circuits everything after it.
  final greeting = await repository
      .getUser('gone')
      .flatMapAsync((user) => repository.getGreeting(user.name))
      .map((line) => line.toUpperCase())
      .onErr((failure) => print('chain failed: ${_describe(failure)}'))
      .getOrElse('(no greeting)');
  print('greeting: $greeting');

  // Several results collapse into one, short-circuiting on the first failure.
  final ids = ['ada', 'grace'];
  final users = Result.collect(
    await Future.wait(ids.map(repository.getUser)),
  ).map((users) => users.map((user) => user.name).join(', '));
  print('collected: ${users.getOrElseWith((f) => f.message)}');
}

/// Presentation picks copy from the failure *type*, never from
/// [Failure.title] — that field is a diagnostic origin, not display text.
String _describe(Failure failure) => switch (failure) {
      ApiFailure(:final message, :final code) => 'server said $code: $message',
      NetworkFailure() => 'Check your connection and try again.',
      AuthFailure() => 'Please sign in again.',
      CancelledFailure() => 'Cancelled.',
      UnknownFailure() => 'Something went wrong.',
    };
1
likes
160
points
230
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A sealed Result type and a structured Failure hierarchy for Dart, so calls return a typed verdict instead of throwing across layer boundaries.

Homepage
Repository (GitHub)
View/report issues

Topics

#result #either #error-handling #functional #sealed

License

MIT (license)

Dependencies

equatable

More

Packages that depend on verdict