
ColaFantaFlutter Error Handling with Result in neverthrow_dart Building a robust Flutter app is...
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.
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:
try/catch code.neverthrow_dart
neverthrow_dart exists because many Result-style APIs look good in toy examples, but become awkward in real app code.
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
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),
);
}
That is the gap $do and $doAsync are trying to close. The flow should read like normal Dart.
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);
}
That looks harmless until a production error reaches your logs with no useful origin.
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)),
);
});
});
});
}
That explicit Either to TaskEither conversion step adds friction to code that should compose naturally.
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),
};
}
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),
});
}
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.
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.
$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;
});
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]);
That keeps failure explicit all the way to the widget layer. Nothing is thrown here unless you choose to throw.
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,
);
}
}
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();
}
The connection is simple:
.$ inside $doAsync to keep multi-step fallible code readable.mapErr(...) and Result.jsonMap(...) to translate and decode safely.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.
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.