DEV Community

Cover image for Flutter Error Handling with Result, introducing neverthrow_dart
ColaFanta
ColaFanta

Posted on

Flutter Error Handling with Result, introducing neverthrow_dart

Flutter Error Handling with Result in neverthrow_dart

Building a robust Flutter app is hard because every layer can fail: network, storage, auth, parsing, and background work. That makes error handling part of the design, not just cleanup code.

Since Dart 3 introduced sealed classes and patterns, typed error handling fits much more naturally in Dart. One of the most useful approaches is the Result pattern.

Why Result is useful

Before Dart 3, many libraries brought this idea to Dart through Either, TaskEither, or custom Result types. The motivation is still the same:

  • A fallible operation becomes explicit in the type signature, so callers know they must handle failure.
  • Success and failure travel through the same return value, which keeps the happy path readable and removes a lot of repetitive try/catch code.
  • Mapping, recovery, and testing become simpler because both branches are regular values.

Why neverthrow_dart

neverthrow_dart exists because many Result-style APIs look good in toy examples, but become awkward in real app code.

1. Naive Result types often lose value semantics

If a Result is just a plain wrapper object, it is easy to lose immutability and value equality.

final a = MyResult.ok(1);
final b = MyResult.ok(1);

print(a == b); // false
Enter fullscreen mode Exit fullscreen mode

2. Without error propagation, you escape try/catch only to land in match hell

Some libraries remove exceptions, but do not give you a satisfying propagation mechanism. You leave try/catch, but land in nested matching instead.

Result<BookingReceipt> reserveRoom() {
  return getGuestNameInput().match(
    (guestName) => getGuestIdInput().match(
      (guestId) => verifyGuestIdFormat(guestId).match(
        (verifiedId) => getStayDateInput().match(
          (stayRange) => validateStayRange(stayRange).match(
            (validRange) => bookRoom(
              guestName: guestName,
              guestId: verifiedId,
              stayRange: validRange,
            ),
            (error) => Err(error),
          ),
          (error) => Err(error),
        ),
        (error) => Err(error),
      ),
      (error) => Err(error),
    ),
    (error) => Err(error),
  );
}
Enter fullscreen mode Exit fullscreen mode

That is the gap $do and $doAsync are trying to close. The flow should read like normal Dart.

3. Some libraries keep the error but lose the stack trace

Dart separates Exception and StackTrace. If a library stores only the exception, the most useful debugging information is already gone.

final class SimpleErr<T> {
  final Exception error;

  SimpleErr(this.error);
}
Enter fullscreen mode Exit fullscreen mode

That looks harmless until a production error reaches your logs with no useful origin.

4. Splitting sync and async results creates a conversion tax

Another common design is to split sync and async into separate types, for example Either and TaskEither. That sounds tidy, but it means every time sync and async code meet, you have to lift and convert values manually.

TaskEither<Exception, UserCard> loadUserCard(String id) {
  return fetchUser(id).flatMap((userResponse) {
    final userEither = parseUserJson(userResponse);

    return TaskEither.fromEither(userEither).flatMap((user) {
      return fetchAvatar(user.avatarId).flatMap((avatarResponse) {
        final avatarEither = parseAvatarJson(avatarResponse);

        return TaskEither.fromEither(
          avatarEither.map((avatar) => UserCard(user: user, avatar: avatar)),
        );
      });
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

That explicit Either to TaskEither conversion step adds friction to code that should compose naturally.

5. Result<T, E> is elegant in theory, but awkward in Dart

Typed error channels work best in languages with ergonomic union types. Dart does not have that for arbitrary error combinations, so Result<T, E> often pushes you into widening, wrapping, and re-wrapping error types.

// Hypothetical API, not this package.
Result<String, ReadFileError> readFileText(String path);
Result<Config, ParseConfigError> parseConfig(String raw);
Result<App, BuildAppError> buildApp(Config config);

Result<String, LoadConfigError> loadConfigText(String path) {
  return switch (readFileText(path)) {
    Ok(:final text) => Ok(text),
    Err(:final error) => Err(LoadConfigReadError(error)),
  };
}

Result<Config, BootstrapError> bootstrapConfig(String path) {
  return switch (loadConfigText(path)) {
    Ok(:final text) => switch (parseConfig(text)) {
      Ok(:final config) => Ok(config),
      Err(:final error) => Err(BootstrapParseError(error)),
    },
    Err(:final error) => Err(BootstrapLoadError(error)),
  };
}

Result<App, BootstrapError> bootstrapApp(String path) {
  return switch (bootstrapConfig(path)) {
    Ok(:final config) => switch (buildApp(config)) {
      Ok(:final app) => Ok(app),
      Err(:final error) => Err(BootstrapBuildError(error)),
    },
    Err(:final error) => Err(error),
  };
}
Enter fullscreen mode Exit fullscreen mode

The problem is not just verbosity. Every layer has to invent one more wrapper error just so the generic E type lines up again.

With Result<T>, the same flow can stay focused on the success path and only translate errors at the boundary where you actually want domain-specific failures:

Result<App> bootstrapApp(String path) {
  return $do(() {
    final text = readFileText(path).$;
    final config = parseConfig(text).$;
    return buildApp(config).$;
  });
}

Result<App> startApp(String path) {
  return bootstrapApp(path).mapErr((error, _) => switch (error) {
    ReadFileError() => const AppStartupFailure.configNotFound(),
    ParseConfigError() => const AppStartupFailure.invalidConfig(),
    BuildAppError() => const AppStartupFailure.bootstrapFailed(),
    _ => AppStartupFailure.unknown(error),
  });
}
Enter fullscreen mode Exit fullscreen mode

This is why neverthrow_dart uses Result<T> and leans on Dart exceptions as the error value. It keeps composition simple while still letting you define domain-specific exception types.

Real-world example of neverthrow_dart

Here are two examples that show the part I find most useful: write several fallible steps in a straight line with .$, then convert back to exceptions only at the framework boundary.

1. Compose multiple fallible steps with $doAsync

FutureResult<BookingReceipt> reserveRoom() => $doAsync(() async {
  final guestName = getGuestNameInput().$;
  final guestId = getGuestIdInput().flatMap(verifyGuestIdFormat).$;
  final stayRange = getStayDateInput().flatMap(validateStayRange).$;

  final receipt = await hotelApi.bookRoom(
      guestName: guestName,
      guestId: guestId,
      stayRange: stayRange,
    ).mapErr((error, _) => switch (error) {
    HttpException(statusCode: 400) => const BookingFailure.invalidRequest(),
    HttpException(statusCode: 404) => const BookingFailure.roomNotFound(),
    HttpException(statusCode: 409) => const BookingFailure.roomAlreadyBooked(),
    _ => BookingFailure.unknown(error),
  }).$;

  return receipt;
});
Enter fullscreen mode Exit fullscreen mode

This is the appealing part. Each step can fail, but the code still reads top to bottom like ordinary async Dart:

  • .$ extracts the successful value.
  • flatMap(...) and mapErr(...) keep validation and error translation in the same flow.

If any step fails, $doAsync stops immediately and returns an Err.

In a hooks-based UI, you can keep the Result in local state and react to it explicitly:

final bookingResult = useState<Result<BookingReceipt>?>(null);

useEffect(() {
  final current = bookingResult.value;
  if (current == null || current.isOk) return null;

  switch (current) {
    case Err(:final e) when e is BookingFailureInvalidRequest:
      showMessage('Please check the guest information.');
    case Err(:final e) when e is BookingFailureRoomNotFound:
      showMessage('This room is no longer available.');
    case Err(:final e) when e is BookingFailureRoomAlreadyBooked:
      showMessage('The room was just booked by someone else.');
    case Err():
      showMessage('Something went wrong. Please try again.');
    case Ok():
      break;
  }

  return null;
}, [bookingResult.value]);
Enter fullscreen mode Exit fullscreen mode

That keeps failure explicit all the way to the widget layer. Nothing is thrown here unless you choose to throw.

2. Connect a Result API to Riverpod

The same flow also fits Riverpod. The main difference is that AsyncValue.guard expects a throwing callback, so this is where orThrow becomes useful.

final bookingProvider =
    AsyncNotifierProvider.autoDispose<BookingNotifier, BookingReceipt?>(
      BookingNotifier.new,
    );

class BookingNotifier extends AsyncNotifier<BookingReceipt?> {
  @override
  BookingReceipt? build() => null;

  FutureResult<BookingReceipt> _reserveRoom(BookingForm form) => $doAsync(() async {
    final guestName = Result.fromNullable(form.guestName).flatMap(requireNotBlank).$;
    final guestId = Result.fromNullable(form.guestId).flatMap(verifyGuestIdFormat).$;

    final response = await Result.future(
      dio.post('/rooms/reserve', data: {
        'guestName': guestName,
        'guestId': guestId,
        'roomId': form.roomId,
      }),
    ).mapErr((error, _) => switch (error) {
      HttpException(statusCode: 400) => const BookingFailure.invalidRequest(),
      HttpException(statusCode: 404) => const BookingFailure.roomNotFound(),
      HttpException(statusCode: 409) => const BookingFailure.roomAlreadyBooked(),
      _ => BookingFailure.unknown(error),
    }).$;

    return Result.jsonMap(BookingReceipt.fromJson)(response.data).$;
  });

  Future<void> submit(BookingForm form) async {
    state = const AsyncValue.loading();

    state = await AsyncValue.guard(
      () => _reserveRoom(form).orThrow,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Inside $doAsync, .$ keeps the workflow linear. Outside $doAsync, .orThrow is the bridge into Riverpod's exception-based API. That makes the boundary clear: typed Result inside your app logic, thrown exceptions only where the framework asks for them. The original stack trace is preserved, so debugging still points back to the real failure site.

In the widget, you pattern match on Riverpod's AsyncValue instead of storing Result directly:

switch (ref.watch(bookingProvider)) {
  case AsyncData(:final value) when value != null:
    return SuccessView(receipt: value);
  case AsyncError(:final error) when error is BookingFailureInvalidRequest:
    return const ErrorView('Please check the guest information.');
  case AsyncError(:final error) when error is BookingFailureRoomNotFound:
    return const ErrorView('This room is no longer available.');
  case AsyncError(:final error) when error is BookingFailureRoomAlreadyBooked:
    return const ErrorView('The room was already booked.');
  case AsyncError():
    return const ErrorView('Something went wrong.');
  default:
    return const LoadingView();
}
Enter fullscreen mode Exit fullscreen mode

The connection is simple:

  • Use .$ inside $doAsync to keep multi-step fallible code readable.
  • Use mapErr(...) and Result.jsonMap(...) to translate and decode safely.
  • Use orThrow only at the Riverpod boundary, where AsyncValue.guard expects exceptions.

In practice, this split works well: keep domain and data layers explicit with Result, then convert only where a framework requires exceptions.

Summary

Result is not about banning exceptions. It is about making expected failure visible, composable, and easier to reason about.

With Dart 3 features like sealed classes and patterns, this style feels much more natural than it used to. neverthrow_dart adds the small utilities that make the pattern practical in Flutter: do-style composition, JSON helpers, async support, and framework-boundary escape hatches like orThrow.

If you want error handling to be more explicit without making the code harder to read, Result is worth serious consideration, and neverthrow_dart is a focused way to apply that pattern in Dart.

Try now:
https://pub.dev/packages/neverthrow_dart

Top comments (0)