Error handling typed results
Skill zakariaf/Flutter-Skills/skills/error-handling-typed-results
General, reusable Flutter engineering skills for AI coding agents — architecture, Riverpod 3.x, testing, persistence, i18n/RTL, accessibility, navigation & more. Agent Skills open standard; works with Claude Code, Cursor, Codex & 70+ agents.
npx -y skills add zakariaf/Flutter-Skills --skill error-handling-typed-resultsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 15 days oldThe repository was created 15 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Enforces a typed-error spine — a hand-rolled sealed Result<T,F> plus one per-boundary sealed Failure carrying a stable code and typed params (never a localized string), returned instead of thrown; recoverable failures are values, only bugs throw. Convert-at-boundary catches narrowly with an on-clause and logs the original error+stack BEFORE returning a typed Failure; call sites switch exhaustively with no default:; the taxonomy of what the global error net (FlutterError.onError + PlatformDispatcher.onError, installed by app-startup-and-bootstrap) routes into, plus Isolate.run re-wrapping; mechanism selection (Error vs assert vs Exception vs sealed outcome) and @useResult; and a never-lose-data layer (one-transaction-per-mutation, debounced autosave drafts, optimistic soft-delete/Undo). Use when writing result.dart/failures.dart, a try/catch or sealed-switch default:, wiring bootstrap handlers, a repository/service/DAO boundary, or transactions, drafts, or soft-delete/Undo.
SKILL.md
14.9 KB, as published. Nobody here has run it
Error Handling — Typed Results
Recoverable failures are typed values that flow through the layers and are switched on exhaustively; only genuine bugs and unrecoverable states throw, and those are caught once by a global net. Wrapped around both, never-lose-data — transactions, autosave drafts, soft-delete/Undo — is a first-class subsystem, not plumbing. Two tiers, no middle ground.
Read the reference for the task at hand:
references/result-failure-spine.md— theResult/Failuresource, the per-boundary taxonomy, convert-at-boundary, the global net, isolate re-wrapping, and local logging.references/mechanism-selection.md— throw vsassertvsExceptionvs sealed outcome,@useResult, and the runZonedGuarded decision.references/never-lose-data.md— one transaction per mutation, debounced autosave drafts, optimistic soft-delete / Trash / Undo behind one filter.
Run scripts/check-swallowed-catch.sh and scripts/check-softdelete-parity.sh before a PR.
Non-negotiable rules
- Model recoverable failures as values, not exceptions. Anything that fails for a runtime reason the caller must handle — DB error, file/backup I/O, notification scheduling, an expected not-found, invalid input — returns
Result<T, F extends Failure>(sealedOk/Err). Throwing across a layer for an expected failure is a review reject. - Hand-roll one zero-dependency sealed
ResultandFailurein a Flutter-free layer so pure logic and repositories share ONE vocabulary.result_dartis the sanctioned drop-in if you want ready-madeflatMap/mapError/AsyncResult— adopt it wholesale or hand-roll; never mix both. - One sealed
Failurefamily per boundary, each subtype carrying a stablecode+ typed params — NEVER a user-facing or localized string. A baked-in message breaks translation, RTL mirroring, and numeral rendering. Localize from thecodeat the presentation edge. switchfailures exhaustively with NOdefault:/case _:. Sealed exhaustiveness turns "added a new failure" into a compile error until every switch covers it; adefault:silently defeats the only compiler-grade safety net you have.- Convert at the boundary — log first, then return. Wrap each dangerous call once, catch narrowly with an
onclause, log the original error + stack to the local log BEFORE returning the typed failure. APlatformException/SqliteException/FormatExceptionnever leaks past its adapter into a Notifier or widget. - Never swallow.
catch (_) {}, barecatch (e)that discards type/stack, andthrow e(userethrow) are banned — CI greps for them. Never catchErrorsubtypes (StateError,AssertionError): those are bugs, let them crash in debug. - Keep pure logic total — it never throws. Every pure function returns a value for every input; uncertainty is an explicit output (an
outOfRangevariant, a clamped value). Programmer invariants useassert(stripped in release). Async/error handling lives outside pure code, at the boundary seam. - Anything thrown is a bug or unrecoverable state, routed to the global net — never a recoverable failure. Recoverable failures are typed
Resultvalues that never throw across a layer, so they never reach the net. The two handlers (FlutterError.onError+PlatformDispatcher.instance.onError) and their install order are owned byapp-startup-and-bootstrap; this skill owns only the taxonomy of what reaches them. TherunZonedGuarded-only-for-a-crash-SDK decision is inreferences/mechanism-selection.md. - Re-wrap
Isolate.run/computeerrors asResultat the call site. Isolate errors do not hitFlutterError.onError; catch them where you await or they propagate opaquely. - Log locally only. A size-capped rotating file plus a user-initiated "Export diagnostics" affordance. If you ship a crash SDK, that is a deliberate choice with its own zone; otherwise no Crashlytics/Sentry/Firebase.
- Never lose hand-entered data. One
transaction(...)per multi-table mutation (all-or-nothing); persist in-progress form state to a debounceddraftstable; delete viais_deletedsoft-delete behind a single shared filter, with SnackBar Undo. Detail inreferences/never-lose-data.md.
The Result + Failure spine
Native Dart 3 sealed, zero dependencies. Pure logic and repositories return the same type.
// lib/core/result.dart — Flutter-free.
sealed class Result<T, F extends Failure> {
const Result();
}
final class Ok<T, F extends Failure> extends Result<T, F> {
const Ok(this.value);
final T value;
}
final class Err<T, F extends Failure> extends Result<T, F> {
const Err(this.failure);
final F failure;
}
extension ResultX<T, F extends Failure> on Result<T, F> {
R fold<R>(R Function(T) onOk, R Function(F) onErr) => switch (this) {
Ok(:final value) => onOk(value),
Err(:final failure) => onErr(failure),
};
Result<R, F> map<R>(R Function(T) f) => switch (this) {
Ok(:final value) => Ok(f(value)),
Err(:final failure) => Err(failure),
};
}
// lib/core/failure.dart — one family per boundary; code + typed params, no strings.
sealed class Failure {
const Failure();
String get code; // stable, localization-key-like, e.g. 'db.not_found'
}
sealed class OrderFailure extends Failure {
const OrderFailure();
}
final class OrderNotFound extends OrderFailure {
const OrderNotFound(this.id);
final String id;
@override
String get code => 'order.not_found';
}
final class OrderConstraintViolated extends OrderFailure {
const OrderConstraintViolated(this.field);
final String field;
@override
String get code => 'order.constraint_violated';
}
final class OrderStoreUnavailable extends OrderFailure {
const OrderStoreUnavailable();
@override
String get code => 'order.store_unavailable';
}
Sealed variants are final class + const ctor + final fields. You switch on the type; you never compare instances, so do not write ==/hashCode or reach for freezed/equatable here.
Convert at the boundary
Wrap the dangerous call once. Log the original exception + stack before returning a typed, string-free failure. @useResult makes discarding the outcome a lint error.
// lib/data/order_repository.dart — the ONLY place the store SDK is imported.
@useResult
Future<Result<Order, OrderFailure>> findOrder(String id) async {
try {
final row = await _dao.byId(id).timeout(const Duration(seconds: 5));
if (row == null) return Err(OrderNotFound(id));
return Ok(row.toDomain());
} on TimeoutException catch (e, st) {
_log.error('order.find.timeout', e, st); // log FIRST — never swallow
return const Err(OrderStoreUnavailable());
} on SqliteException catch (e, st) {
_log.error('order.find.db', e, st);
return Err(_mapDbException(e)); // typed, stable code, no strings
}
}
Exhaustive switch at the call site
The Notifier (ViewModel) switches every case; localization happens here, from the code.
// lib/features/order/order_notifier.dart — manual Riverpod 3.x AsyncNotifier
// ViewModel. Hand-written providers are the default; `@riverpod` codegen is an
// optional alternative (see state-management-riverpod). `autoDispose`/`family`
// are provider MODIFIERS — never AutoDispose*/*Family base classes, which were
// removed in 3.0. The family arg (the order id) is read via `arg`.
final orderNotifierProvider =
AsyncNotifierProvider.autoDispose.family<OrderNotifier, Order, String>(
OrderNotifier.new);
class OrderNotifier extends AsyncNotifier<Order> {
@override
Future<Order> build() async {
switch (await ref.watch(orderRepositoryProvider).findOrder(arg)) {
case Ok(:final value):
return value;
case Err(:final failure): // sealed => no default:; adding a case is a compile error
throw OrderLoadException(failure); // surfaces as AsyncValue.error to the View
}
}
}
// A View renders AsyncValue loading/error/data; it maps failure.code to a localized string.
For UI that recovers per-case rather than showing a generic error, switch the failure in the widget and map each code to an l10n message — never a default:.
The global net routes only thrown bugs
app-startup-and-bootstrap owns the net and everything about it: the two handlers — FlutterError.onError (build/layout/paint sync errors) and PlatformDispatcher.instance.onError (async + platform-channel errors) — installed before runApp, onError returning true, no runZonedGuarded, and ProviderException unwrapped before logging. Do not restate that bootstrap() here.
What this skill owns is the taxonomy of what reaches those handlers: only genuine bugs and unrecoverable states. Every recoverable failure is a typed Result/Failure value that never throws across a layer, so it never reaches the net. Isolate errors are the one thrown category the net misses — re-wrap them as Result at the call site (below).
Isolate error re-wrapping
// Heavy work runs off the UI isolate; its errors do NOT reach FlutterError.onError.
Future<Result<Report, ComputeFailure>> buildReport(ReportInput input) async {
try {
return Ok(await Isolate.run(() => ReportBuilder().run(input)));
} on Object catch (e, st) {
_log.error('report.compute', e, st);
return const Err(ComputeFailed('report'));
}
}
Anti-patterns
catch (_) {}/ barecatch (e)— discards type and stack; fatal when the lost data can't be re-fetched. Catch narrowly and log(e, st).- A user-facing
Stringinside aFailure/Exception— breaks translation, RTL mirroring, numeral rendering. Codes + typed params only. default:/case _:on a sealed switch — a new failure subtype slips through unhandled; you lose compile-time exhaustiveness.throw e;in a catch — resets the stack to the rethrow line. Userethrow.- A low-level exception in the UI —
PlatformException/SqliteException/FormatExceptionreaching a Notifier or widget. Convert it at the adapter. throwinside pure logic for uncertainty — keep it total; return an explicit "cannot within budget" value.- Catching
Errorsubtypes "to be safe" — you are hiding a bug; let it crash in debug. - A generic
Result<T>whose error arm isException/Object— no type to switch on; every case gets silently dropped. Type the error asF extends Failure. asserton a platform/plugin return value — the assert is stripped in release, so the real-device failure becomes total silence. Return a typed failure instead.- Treating notification/permission/plugin errors as sync — they are async MethodChannel errors; catch at the call site AND rely on
PlatformDispatcher.onError. - A multi-table write without a transaction — a half-applied write silently corrupts derived data with no way to notice offline.
- A second analytics/chart query that bypasses the shared soft-delete filter — deleted rows silently pollute reports. One filter, every read.
Definition of done
- Recoverable failures return a sealed
Result<T, F>; every call siteswitches all cases with nodefault:. - Each boundary owns one sealed
Failure; every subtype carries a stablecode+ typed params, zero localized strings. - Pure logic stays total (returns, never throws); error handling lives at the repository/service/DAO seam.
- Boundary catches are narrow (
onclauses), log(e, st)FIRST, then return a typedErr; no bare/empty catch;rethrowpreserves stacks. - Only bugs/unrecoverable states are thrown into the global net (installed by
app-startup-and-bootstrap); no recoverable failure throws across a layer. -
Isolate.run/computecalls re-wrap errors asResultat the call site. - Multi-table mutations run in one transaction; in-progress forms autosave on a debounce; deletes are soft + reversible behind a single shared filter.
-
scripts/check-swallowed-catch.shandscripts/check-softdelete-parity.shpass.
Related skills
- See
async-safetyfor the arrow-callback Future-drop hole no lint catches,unawaited/.timeout/mounteddiscipline, and subscription/timer disposal. - See
dart3-idioms-and-coding-standardsfor sealed classes, exhaustiveswitchexpressions, and total non-throwing functions. - See
state-management-riverpodfor AsyncNotifier ViewModels,AsyncValueloading/error/data, and providers-as-DI. - See
app-startup-and-bootstrapformain()ordering and where the global error handlers install. - See
persistence-driftfor the DAO/transaction machinery,run-migrationfor schema evolution. - See
service-boundary-and-nativefor the injectable-interface seam where SDK/plugin exceptions are caught and converted. - See
i18n-rtl-l10nfor mapping a failurecodeto a localized message and for numeral normalize-before-parse (do not duplicate it here). - See
testing-strategyfor asserting onErrbranches andfake_asyncclock-driven debounce/purge tests. - See
lint-and-style-configfor promotingavoid_catches_without_on_clauses,unawaited_futures, andunused_resultto errors.
References
- Dart — Error handling (exceptions vs errors,
rethrow, catch specificity) - Dart — Branches: exhaustive switch and class modifiers:
sealed - Dart — Concurrency (
Isolate.run, message passing) - Flutter — Handling errors in Flutter (
FlutterError.onError,PlatformDispatcher.instance.onError) - Riverpod — AsyncNotifier / AsyncValue
- pub.dev — result_dart · clock · meta (
@useResult)