Skip to content
Back to skills

Error Handling Typed Results

ASecurity

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 + PlatformDisp...

  • 15 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 19, 2026
ai-agentsgosqlexpresstesting

Security analysis

A100/100

Pro scans all 9 files and shows the line behind each finding

Scanned September 19, 2026

npx -y skills add zakariaf/Flutter-Skills --skill error-handling-typed-results --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Error Handling Typed Results?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Error Handling Typed Results
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/zakariaf-error-handling-typed-results/badge)](https://www.skillsdirectory.com/skills/zakariaf-error-handling-typed-results)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: error-handling-typed-results
description: 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.
---

# 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` — the `Result`/`Failure` source, the per-boundary taxonomy, convert-at-boundary, the global net, isolate re-wrapping, and local logging.
- `references/mechanism-selection.md` — throw vs `assert` vs `Exception` vs 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

1. **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>` (sealed `Ok`/`Err`). Throwing across a layer for an *expected* failure is a review reject.
2. **Hand-roll one zero-dependency sealed `Result` and `Failure` in a Flutter-free layer** so pure logic and repositories share ONE vocabulary. `result_dart` is the sanctioned drop-in if you want ready-made `flatMap`/`mapError`/`AsyncResult` — adopt it wholesale or hand-roll; never mix both.
3. **One sealed `Failure` family per boundary, each subtype carrying a stable `code` + typed params — NEVER a user-facing or localized string.** A baked-in message breaks translation, RTL mirroring, and numeral rendering. Localize from the `code` at the presentation edge.
4. **`switch` failures exhaustively with NO `default:` / `case _:`.** Sealed exhaustiveness turns "added a new failure" into a *compile error* until every switch covers it; a `default:` silently defeats the only compiler-grade safety net you have.
5. **Convert at the boundary — log first, then return.** Wrap each dangerous call once, catch narrowly with an `on` clause, **log the original error + stack to the local log BEFORE returning** the typed failure. A `PlatformException`/`SqliteException`/`FormatException` never leaks past its adapter into a Notifier or widget.
6. **Never swallow.** `catch (_) {}`, bare `catch (e)` that discards type/stack, and `throw e` (use `rethrow`) are banned — CI greps for them. Never catch `Error` subtypes (`StateError`, `AssertionError`): those are bugs, let them crash in debug.
7. **Keep pure logic total — it never throws.** Every pure function returns a value for every input; uncertainty is an explicit output (an `outOfRange` variant, a clamped value). Programmer invariants use `assert` (stripped in release). Async/error handling lives *outside* pure code, at the boundary seam.
8. **Anything *thrown* is a bug or unrecoverable state, routed to the global net — never a recoverable failure.** Recoverable failures are typed `Result` values 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 by `app-startup-and-bootstrap`; this skill owns only the taxonomy of what reaches them. The `runZonedGuarded`-only-for-a-crash-SDK decision is in `references/mechanism-selection.md`.
9. **Re-wrap `Isolate.run`/`compute` errors as `Result` at the call site.** Isolate errors do **not** hit `FlutterError.onError`; catch them where you await or they propagate opaquely.
10. **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.
11. **Never lose hand-entered data.** One `transaction(...)` per multi-table mutation (all-or-nothing); persist in-progress form state to a debounced `drafts` table; delete via `is_deleted` soft-delete behind a single shared filter, with SnackBar Undo. Detail in `references/never-lose-data.md`.

## The Result + Failure spine

Native Dart 3 `sealed`, zero dependencies. Pure logic and repositories return the same type.

```dart
// 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),
      };
}
```

```dart
// 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.

```dart
// 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`.

```dart
// 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

```dart
// 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 (_) {}` / bare `catch (e)`** — discards type *and* stack; fatal when the lost data can't be re-fetched. Catch narrowly and log `(e, st)`.
- **A user-facing `String` inside a `Failure`/`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. Use `rethrow`.
- **A low-level exception in the UI** — `PlatformException`/`SqliteException`/`FormatException` reaching a Notifier or widget. Convert it at the adapter.
- **`throw` inside pure logic for uncertainty** — keep it total; return an explicit "cannot within budget" value.
- **Catching `Error` subtypes "to be safe"** — you are hiding a bug; let it crash in debug.
- **A generic `Result<T>` whose error arm is `Exception`/`Object`** — no type to switch on; every case gets silently dropped. Type the error as `F extends Failure`.
- **`assert` on 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 site `switch`es all cases with no `default:`.
- [ ] Each boundary owns one sealed `Failure`; every subtype carries a stable `code` + 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 (`on` clauses), log `(e, st)` FIRST, then return a typed `Err`; no bare/empty catch; `rethrow` preserves 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`/`compute` calls re-wrap errors as `Result` at 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.sh` and `scripts/check-softdelete-parity.sh` pass.

## Related skills

- See `async-safety` for the arrow-callback Future-drop hole no lint catches, `unawaited`/`.timeout`/`mounted` discipline, and subscription/timer disposal.
- See `dart3-idioms-and-coding-standards` for sealed classes, exhaustive `switch` expressions, and total non-throwing functions.
- See `state-management-riverpod` for AsyncNotifier ViewModels, `AsyncValue` loading/error/data, and providers-as-DI.
- See `app-startup-and-bootstrap` for `main()` ordering and where the global error handlers install.
- See `persistence-drift` for the DAO/transaction machinery, `run-migration` for schema evolution.
- See `service-boundary-and-native` for the injectable-interface seam where SDK/plugin exceptions are caught and converted.
- See `i18n-rtl-l10n` for mapping a failure `code` to a localized message and for numeral normalize-before-parse (do not duplicate it here).
- See `testing-strategy` for asserting on `Err` branches and `fake_async` clock-driven debounce/purge tests.
- See `lint-and-style-config` for promoting `avoid_catches_without_on_clauses`, `unawaited_futures`, and `unused_result` to errors.
- See `ui-states-and-feedback` for how a `Failure` code becomes an error state, a snackbar, or an Undo — never a rendered exception.
- See `data-export-and-restore` for the user-facing end of never-lose-data: versioned backups and an all-or-nothing restore.

## References

- Dart — [Error handling](https://dart.dev/language/error-handling) (exceptions vs errors, `rethrow`, catch specificity)
- Dart — [Branches: exhaustive switch](https://dart.dev/language/branches#exhaustiveness-checking) and [class modifiers: `sealed`](https://dart.dev/language/class-modifiers#sealed)
- Dart — [Concurrency](https://dart.dev/language/concurrency) (`Isolate.run`, message passing)
- Flutter — [Handling errors in Flutter](https://docs.flutter.dev/testing/errors) (`FlutterError.onError`, `PlatformDispatcher.instance.onError`)
- Riverpod — [AsyncNotifier / AsyncValue](https://riverpod.dev/docs/concepts/providers)
- pub.dev — [result_dart](https://pub.dev/packages/result_dart) · [clock](https://pub.dev/packages/clock) · [meta (`@useResult`)](https://pub.dev/packages/meta)

Files in this skill

  • SKILL.md15.1 KB
  • examples/soft_delete_undo.dart2.7 KB
  • examples/transactional_write.dart3.2 KB
  • examples/typed_result_boundary.dart4.2 KB
  • references/mechanism-selection.md6.2 KB
  • references/never-lose-data.md8.6 KB
  • references/result-failure-spine.md10.3 KB
  • scripts/check-softdelete-parity.sh2.1 KB
  • scripts/check-swallowed-catch.sh3.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.