Skip to content
Back to skills

Error Handling Typed Results

ASecurity

Builds a typed Result spine - failures converted at boundaries, exhaustive handling. Use when writing try/catch, .catch or a Result, or fixing swallowed errors or unhandled rejections. Not for error UI (ui-states-and-feedback).

  • 3 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 18, 2026
developmenttypescriptgosqlreactnodeapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

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

Scanned October 18, 2026

npx -y skills add zakariaf/ReactNative-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-reactnative-skills/badge)](https://www.skillsdirectory.com/skills/zakariaf-error-handling-typed-results-reactnative-skills)

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: Builds a typed Result spine - failures converted at boundaries, exhaustive handling. Use when writing try/catch, .catch or a Result, or fixing swallowed errors or unhandled rejections. Not for error UI (ui-states-and-feedback).
---

# Error handling with typed results

Expected failures travel as typed values (`Result<T, F>` with a `kind` union) that every caller must
handle; only bugs throw, and those land in one global error net and a recording error boundary. No error
path ever destroys the user's data. Two zero-dependency checkers prove what ESLint and `tsc` cannot see.

## Rules that must hold

1. **Expected failures are values; only bugs throw.** Anything that can happen in a correct program (not
   found, offline, storage full, invalid input, cancelled, a corrupt row) returns `Result<T, F>`; a broken
   invariant throws `new Error(message, { cause })`. Why: the type system then forces every caller to handle
   every expected case, and a thrown expected failure crashes a handler or is swallowed by a caller.
2. **One Result type, from `src/core/result.ts`.** `{ ok: true, value } | { ok: false, error }` with `ok`,
   `err`, `attempt`, `attemptAsync`; no second Result/Either and no result library. Why: one vocabulary
   from core to screen; two shapes need adapter code everywhere they meet.
3. **A failure is a kebab-case `kind` with typed params, never text or an Error.** `err({ kind:
   'task-title-taken', title })`; never `err('...')`, `err(new Error())`, `Result<T, string | Error |
   unknown>`, nor a `message` in a failure. Why: a string cannot be switched on exhaustively or translated,
   and baked-in English breaks an Arabic UI.
4. **Convert at the boundary, once.** Adapters, repositories, the ApiClient and parsers catch the vendor's
   error and return the boundary's failure family; no `await` in an adapter escapes outside try/catch or
   `attemptAsync`. Why: above the boundary nobody knows SQLite or StoreKit error shapes, and an escaped
   rejection is dropped silently in release builds.
5. **Every catch maps, records with a fallback, or rethrows with `{ cause }`.** Console-only, comment-only
   and `.catch(() => undefined)` handlers fail; a cosmetic effect may ignore its failure only inside its
   adapter with a `// best-effort: <reason>` comment. Why: nobody reads the console on a user's phone, so
   a swallowed error leaves no trace, and a rethrow without `cause` loses the original stack.
6. **Record the unexpected, with the original error, before returning the failure.** The error log gets
   the raw error; expected failures (user input, offline) are not recorded. Why: after the conversion only
   `{ kind, params }` exists, and a log full of expected failures hides real defects.
7. **Handle every failure exhaustively and never drop a Result.** Branch on `error.kind` with a `switch`
   or a `Record<F['kind'], ...>` table, never an `if/else` whose `else` absorbs future kinds; a call that
   returns a Result is never a bare statement (`if (x) save(t);` included), a `forEach` callback's body or
   an `on*` arrow body. Why: a new kind becomes a compile error everywhere it must be handled, and
   `no-floating-promises` cannot see a dropped `Result`.
8. **The UI shows a message chosen by kind, never `error.message`.** One table per failure family maps
   each kind to a catalog key (params become ICU values) and every key exists in every locale. Why:
   `error.message` is English, technical and can hold user data; a missing key renders a raw id.
9. **One global error net, installed by the root layout's first app import and connected by the
   composition root.** It forwards to the handler it replaced, enables Hermes rejection tracking in every
   build (nothing in the net tests `__DEV__`), buffers until the composition root connects the error log
   (before any provider renders), and calls its sink inside try/catch. Why: React Native 0.86 tracks
   unhandled rejections only in `__DEV__`,
   a module that throws while loading runs before any install call in a module body, and a net nobody
   connects keeps every report in memory until the process ends.
10. **The root route exports an `ErrorBoundary` that records the error and offers one way out.** It never
    retries or navigates away from an effect or a timer, and its fallback stays trivial and reads no
    context that lacks a default. Why: render errors never reach the net (React Native reports them
    straight to its ExceptionsManager) and Expo Router's boundary records nothing, so its `record` is the
    only record; an automatic retry of a repeating render error is a crash loop; and the fallback renders
    outside every provider, where an error it throws is a fatal crash nobody records.
11. **No error path destroys data.** No delete, drop, clear, truncate or reset inside a catch, a `.catch`
    handler or a failure branch; a bad record is quarantined and the rest loads; data newer than the app
    is left untouched. Why: "reset on error" turns a recoverable failure into permanent loss.
12. **A failed user write keeps the input and says so.** The model clears a form only after `ok`; a
    failure becomes a message with a retry; user writes are never fire-and-forget. Why: a silent failed
    save is data loss the user discovers days later.

## Workflow

1. **Choose the mechanism** for each failure with the table in
   [references/result-and-failures.md](references/result-and-failures.md) (section 1): Result, `T | null`,
   throw, boundary or net.
2. **Add the Result module** when `src/core/result.ts` is missing: copy `templates/src/core/result.ts`
   and its test (line 1 names the path). Other skills cite this file; extend it in place (a helper earns
   its place at three call sites, with a test), never with a second Result module.
3. **Design one failure family per boundary**, imitating
   [examples/tasks/task-failure.ts](examples/tasks/task-failure.ts) (sections 4 and 5 of the result
   reference: kinds, params, layering).
4. **Convert at the boundary**: read [references/boundaries-and-catches.md](references/boundaries-and-catches.md),
   imitate [examples/tasks/to-storage-failure.ts](examples/tasks/to-storage-failure.ts) (and its test on
   real `node:sqlite` errors) and [examples/tasks/task-repository.ts](examples/tasks/task-repository.ts) (and its test);
   wrap adapter calls with `attemptAsync`, and a call that can hang with async-safety's
   `withTimeout((signal) => Promise<Result>, afterMs, signal?)` (section 7); a cosmetic adapter follows
   [examples/haptics-adapter.ts](examples/haptics-adapter.ts).
5. **Handle and present**: switch or table on `kind`; add a message table like
   [examples/tasks/task-failure-message.ts](examples/tasks/task-failure-message.ts), put its keys in every
   catalog, and add the per-locale test from [references/test-recipes.md](references/test-recipes.md)
   (section 2). Screens call a void intent of the model hook, never a Result function.
6. **Protect the user's data**: follow [references/never-lose-data.md](references/never-lose-data.md);
   the model keeps input until `ok`, as [examples/tasks/use-add-task-model.ts](examples/tasks/use-add-task-model.ts)
   and its test show.
7. **Install the global net** (once per app): copy `templates/src/services/error-net/` (`error-net.ts`,
   `app-error-net.ts`, `install-error-net.ts`, each with its test); make
   `import '@/services/error-net/install-error-net';` the first app import of `src/app/_layout.tsx`
   (or the line after the Intl polyfills in `index.ts`); connect the error log right after the composition
   root creates the services, in the root layout's `useState` initializer, before any provider renders
   ([examples/root-layout.tsx](examples/root-layout.tsx), [examples/connect-error-log.ts](examples/connect-error-log.ts)).
   Read [references/global-error-net.md](references/global-error-net.md) (section 5 for connecting and
   durability) before changing the net, and section 8 there when the app ships a crash reporter.
8. **Add the root error boundary**: copy `templates/src/features/crash-recovery/`
   (`create-route-error-boundary.tsx`, `crash-screen.tsx`, `crash-fallback.tsx`, their tests). The
   fallback reads no provider: the background from `useTheme()` (its context defaults to the light
   theme) and copy from `createTranslateNow(onError)` in `src/i18n/translate-now.ts`
   (i18n-strings-and-catalogs owns it; it keeps the language the app showed last), wrapped in
   `TextLocaleProvider` with that same `languageNow()` language and `directionOf(language)`, so an RTL
   crash text is laid out right to left (rtl-and-direction's `root-direction-provider`). Put `crash.title`,
   `crash.body` and `crash.restart` in every catalog (`tsc` fails on a missing source key). Export
   `ErrorBoundary` from the root layout with `record` reaching `appErrorNet.report` and
   `restart: () => reloadAppAsync(...)`. The tests import unit-and-component-tests' router helper
   (`test/render-router-with-providers.tsx`) and accessibility-as-code's audit
   (`test/a11y/find-inaccessible-pressables.ts`); when either is missing, set it up with that skill
   first. They render the boundary and the fallback with no provider on purpose, under the documented
   `// no-providers: <why>` marker (section 5 of the test recipes).
9. **Test**: the recipes in [references/test-recipes.md](references/test-recipes.md); then `npm run
   typecheck`, `npx eslint . --max-warnings 0` and `npx jest --ci`.
10. **Run the checkers** from the repo root; look up any rule in
    [references/checker-rules.md](references/checker-rules.md); fix and rerun until both print
    `RESULT: PASS`.
11. **On a device** (once per release candidate, by hand): prove a rejection, a render error and a
    handler error each reach the error log in a release build on a named simulator or emulator (recipe in
    section 10 of the net reference).

## Definition of done

- [ ] `src/core/result.ts` comes from the template (any helper added there, with a test); no other Result/Either type and no result library.
- [ ] Every expected failure is a `kind` union with typed params; no `err('...')`, `err(new Error())`, `Result<T, string | Error | unknown>` or `message` field.
- [ ] Each boundary converts once, records unexpected failures with the original error, and no adapter `await` escapes.
- [ ] Every catch maps, records with a fallback, or rethrows with `{ cause }`; `// best-effort:` appears only in adapters.
- [ ] Failures are handled with a `switch` or a `Record<F['kind'], ...>`; no Result is dropped; handlers call void intents.
- [ ] Every failure family a screen shows has a message table; its keys exist in every locale (per-locale test); no `error.message` in UI.
- [ ] No destructive call on an error path; input survives a failed write (test); quarantine and newer-version outcomes are typed.
- [ ] The net is the root layout's first app import; the composition root connects the error log before any provider renders; the root `ErrorBoundary` records and offers one way out.
- [ ] `npm run typecheck`, `npx eslint . --max-warnings 0` and `npx jest --ci` pass.
- [ ] `node ${CLAUDE_SKILL_DIR}/scripts/check-error-net.mjs .` prints `RESULT: PASS`. Allowed only while the root layout is not yet created:
  - `SKIP src/app/_layout.tsx [net-installed] not yet created (app-startup-and-bootstrap creates it)`
  - `SKIP src/app/_layout.tsx [root-boundary] not yet created (app-startup-and-bootstrap creates it)`
  - `SKIP src/app/_layout.tsx [net-connected] not yet created (app-startup-and-bootstrap creates it)`
- [ ] `node ${CLAUDE_SKILL_DIR}/scripts/check-error-spine.mjs .` prints `RESULT: PASS` (allowed: `SKIP src/i18n/catalogs/en.json [failure-message-key] not yet created (i18n-strings-and-catalogs creates it)`).

## Anti-patterns

- **`catch (error) { console.warn(error); return []; }`** - nobody reads the console on a user's phone.
  Map it, record it with a fallback, or rethrow with `{ cause }`.
- **`catch { return null; }`** - a failure turns into "not found" and the error is gone. Return
  `err({ kind })`, or bind the error and record it before the fallback.
- **`err('Could not save')` or `{ kind, message }`** - untranslatable and impossible to switch on.
  Carry the kind and params; the screen picks the message key.
- **`<AppText>{error.message}</AppText>`** - shows English internals (and sometimes user data). Show the
  failure's message key; send the error to the log.
- **Wrapping a whole function in try/catch** - turns typos into "storage unavailable". Wrap the vendor
  call only, with `attempt`/`attemptAsync`.
- **`onPress={() => repository.add(draft)}`** - the Result vanishes into a void handler. Call
  `model.add()`, which handles it.
- **`titles.forEach((title) => repository.add({ id: title, title }))`** - every failed write vanishes.
  Loop with `for...of` and branch on each Result.
- **`if (failure.kind === 'a') ... else ...`** - a kind added later silently lands in the `else`. Use a
  `switch` (lint demands every case) or a `Record<F['kind'], ...>` table.
- **`catch { deleteDatabaseSync(name); }` "to start fresh"** - permanent loss. Keep the file, show the
  failure, open read-only.
- **Resetting the form on submit** - a failed save erases the input. Clear after `ok`.
- **`appErrorNet.install()` in the layout's body** - every import already ran. Use the side-effect
  import first.
- **`if (__DEV__)` anywhere in the net** (around the tracker, the install call or `previous(error,
  isFatal)`) - release builds, the only ones nobody watches, lose rejections or stop crashing on fatal
  errors. The net is the same in every build.
- **`catch { return false; }` around `new URL(raw)` or a feature probe** - looks like a swallowed defect.
  The throw is the answer: `attempt(() => new URL(raw), () => ({ kind: 'invalid-url' }) as const).ok`.
- **A fallback that calls `retry()` in an effect, `setTimeout(onRestart, 3000)` or
  `router.replace('/')` after a delay** - a crash loop when the error repeats. One button, the user decides.
- **A net nobody connects** - every report waits in its buffer until the process ends. Connect the error
  log right after the composition root creates the services.
- **A fallback that calls `useT()` or a store hook** - the providers sit inside the crashed layout, so
  the fallback itself throws, and that crash is never recorded. Read no context that lacks a default
  (`useTheme()` has one); take copy from `createTranslateNow` (`src/i18n/translate-now.ts`).
- **`APP_THEMES` imported for a "static" crash background** - design-system-structure forbids palette
  imports, and the theme context's default already is the light theme. Use `useTheme()`.
- **`record: (error) => { console.warn(error); }`** - the boundary's `record` is the only record of a
  render error; send it to `appErrorNet.report`.
- **A second `ErrorUtils.setGlobalHandler`** - it replaces the net. Report through `appErrorNet`.

## Files in this skill

| File | What it is | Read/run when |
|---|---|---|
| [references/result-and-failures.md](references/result-and-failures.md) | Mechanism table, the Result type and why, attempt/attemptAsync, failure design and layering, exhaustive handling, dropped Results, messages, pitfalls | Workflow steps 1-3 and 5 |
| [references/boundaries-and-catches.md](references/boundaries-and-catches.md) | Where boundaries are, the conversion pattern, verified vendor error shapes (expo-sqlite per platform, node:sqlite, CodedError, fetch), recording, the three catch outcomes, best-effort, timeouts, adapters | Workflow step 4 |
| [references/global-error-net.md](references/global-error-net.md) | What reaches the net (render errors do not), React Native 0.86 dev/release behaviour on iOS and Android, the template net, install order, connecting the log, Expo Router's ErrorBoundary, crash loops, Sentry, web, device proof | Workflow steps 7, 8 and 11 |
| [references/never-lose-data.md](references/never-lose-data.md) | The four data-loss paths, no destructive recovery, keeping input, quarantine, read-only newer data, visible failures, tests | Workflow step 6 |
| [references/test-recipes.md](references/test-recipes.md) | Asserting Results, per-locale message test, mappers on real errors, net and boundary tests, input survival, test-first order | Workflow steps 5 and 9 |
| [references/checker-rules.md](references/checker-rules.md) | Every rule of both checkers, limits, configuration keys (`sourceCatalog`, `resultModule`), multi-package runs, SKIP and NOT APPLICABLE lines | Workflow step 10, on any FAIL line |
| `templates/src/core/result.ts` | The canonical Result module (synced from the library; do not edit here) | Workflow step 2 |
| `templates/src/core/result.test.ts` | Its tests (synced from the library) | Workflow step 2 |
| `templates/src/services/error-net/error-net.ts` | `createErrorNet` and `readGlobalErrorHooks`: chain, rejection tracking, buffer, guarded sink | Workflow step 7 |
| `templates/src/services/error-net/error-net.test.ts` | Its tests with fake hooks | Workflow step 7 |
| `templates/src/services/error-net/app-error-net.ts` | The app's one net instance | Workflow step 7 |
| `templates/src/services/error-net/app-error-net.test.ts` | Its test against jest-expo's real ErrorUtils | Workflow step 7 |
| `templates/src/services/error-net/install-error-net.ts` | The side-effect installer the root layout imports first | Workflow step 7 |
| `templates/src/services/error-net/install-error-net.test.ts` | Its test: installs once, restores | Workflow step 7 |
| `templates/src/features/crash-recovery/create-route-error-boundary.tsx` | Factory for a route's `ErrorBoundary`: records once, one way out | Workflow step 8 |
| `templates/src/features/crash-recovery/create-route-error-boundary.test.tsx` | Unit tests without providers and `renderRouterWithProviders` tests through Expo Router (a throwing route, a throwing root layout) | Workflow step 8 |
| `templates/src/features/crash-recovery/crash-screen.tsx` | The crash fallback view (translated strings and the theme background as props; React Native's `Button`, marked `// press-outside-host:` for ui-component-library) | Workflow step 8 |
| `templates/src/features/crash-recovery/crash-screen.test.tsx` | Its tests: copy, one way out by role and name, no press no call, the accessibility audit | Workflow step 8 |
| `templates/src/features/crash-recovery/crash-fallback.tsx` | The app's root fallback: crash screen with `createTranslateNow` copy, its own `TextLocaleProvider` (language and direction from `languageNow()`) and the `useTheme()` background, no app provider needed | Workflow step 8 |
| `templates/src/features/crash-recovery/crash-fallback.test.tsx` | Its test, rendered with no provider at all (the only proof it survives outside the layout), plus the writing direction per language | Workflow step 8 |
| [examples/tasks/task-failure.ts](examples/tasks/task-failure.ts) | Shape: a failure family and its kinds as data | Workflow step 3 |
| [examples/tasks/to-storage-failure.ts](examples/tasks/to-storage-failure.ts) | Shape: a total mapper from SQLite errors (both expo-sqlite device forms) | Workflow step 4 |
| [examples/tasks/to-storage-failure.test.ts](examples/tasks/to-storage-failure.test.ts) | Shape: the mapper tested on real node:sqlite errors and the iOS and Android message forms | Workflow step 4 |
| [examples/tasks/task-repository.ts](examples/tasks/task-repository.ts) | Shape: a boundary converting once and recording the unexpected (only `tasks.title` is the user's duplicate; a reused id is a recorded defect) | Workflow step 4 |
| [examples/tasks/task-repository.test.ts](examples/tasks/task-repository.test.ts) | Shape: the repository on real SQL: a duplicate title is expected and unrecorded, an id clash is recorded | Workflow step 4 |
| [examples/haptics-adapter.ts](examples/haptics-adapter.ts) | Shape: the one licensed best-effort swallow | Workflow step 4 |
| [examples/tasks/task-failure-message.ts](examples/tasks/task-failure-message.ts) | Shape: kind to message key table with ICU values | Workflow step 5 |
| [examples/tasks/use-add-task-model.ts](examples/tasks/use-add-task-model.ts) | Shape: a model hook that keeps input until `ok` | Workflow step 6 |
| [examples/tasks/use-add-task-model.test.ts](examples/tasks/use-add-task-model.test.ts) | Shape: the input-survives test | Workflow step 6 |
| [examples/root-layout.tsx](examples/root-layout.tsx) | Shape: the root layout's net import, the composition root connecting the log, the `ErrorBoundary` export | Workflow steps 7-8 |
| [examples/connect-error-log.ts](examples/connect-error-log.ts) | Shape: connecting the error log to the net | Workflow step 7 |
| `scripts/check-error-spine.mjs` | Checker: Result module, failure shape and text, catches, causes, adapters, dropped Results, UI messages, catalog keys, destructive recovery | Workflow step 10, definition of done |
| `scripts/check-error-net.mjs` | Checker: one net that forwards, tracks rejections, guards its sink, is installed first and connected; a recording root boundary without auto-retry | Workflow step 10, definition of done |
| `scripts/lib/error-scan.mjs` | Shared scans of both checkers (catch blocks, handlers, failure branches) | Never by hand |
| `scripts/lib/source-scan.mjs` | Lexer and import resolution (synced from the library; do not edit here) | Never by hand |
| `scripts/check-lib.mjs` | Shared checker library (synced from the library; do not edit here) | Never by hand |
| `scripts/selftest.mjs` | Proves both checkers against the fixtures (`node scripts/selftest.mjs`) | After changing a checker |
| `assets/shared.json` | The shared files this skill copies in | When adding a shared file |
| `tests/bases/` | The base app the fixtures assemble with the templates and examples; the canonical error-log port, memory adapter and `src/i18n/translate-now.ts` are synced in (do not edit those copies) | When a fixture needs a new base file |
| `tests/fixtures/` | Overlay cases: good, pass-*, one bad-* per rule, error-* | When adding a rule |

## Related skills

- `typescript-and-lint-rules` - the lint rules this builds on (`switch-exhaustiveness-check`, `only-throw-error`, `no-empty`, `kindValue`); it cites `src/core/result.ts`.
- `architecture-and-boundaries` - ports and adapters, the composition root, the `ErrorLogPort` the net's sink writes to.
- `async-safety` - floating promises, synchronous handlers, `.catch(reportError)`, cancellation.
- `persistence-and-migrations` - the `SqlDriver`, transactions, quarantine in the save-document appendix, and newer-file handling (never touch a file the build cannot use); `run-migration` to ship a schema step.
- `networking-and-server-state` - the ApiClient's HTTP failure family and retries.
- `ui-states-and-feedback` - how a failure message is shown: inline, banner, dialog, retry, Undo.
- `i18n-strings-and-catalogs` - the catalogs the failure-message keys live in, and `src/i18n/translate-now.ts` (`createTranslateNow`), the fallback's t() without a provider.
- `design-system-structure` - `AppText`, `useTheme()` and the theme context's light default the crash screen's background comes from.
- `app-startup-and-bootstrap` - the full startup order around the net import.
- `crash-reporting-and-observability` - Sentry behind `CrashReporterPort`, fed from the net's sink.
- `react-components-and-hooks` - component rules for the fallback and model hooks.
- `forms-and-input` - drafts and field errors that keep input.
- `debug-menu-and-test-hooks` - the test-only triggers for the device proof.
- `unit-and-component-tests` - Jest projects, the render helpers (`renderRouterWithProviders`) and the `no-providers` marker the recipes use.
- `accessibility-as-code` - `findInaccessiblePressables`, the audit the crash screen's test ends with.
- `rtl-and-direction` - the crash fallback's own `TextLocaleProvider` (`languageNow()`, `directionOf`) and its `root-direction-provider` rule.
- `ui-component-library` - why the crash screen's core `Button` carries the `press-outside-host` marker.
- `data-export-and-restore` - backups and all-or-nothing restore.

Files in this skill

  • SKILL.md23.7 KB
  • assets/shared.json766 B
  • examples/connect-error-log.ts1.1 KB
  • examples/haptics-adapter.ts1 KB
  • examples/root-layout.tsx2.4 KB
  • examples/tasks/task-failure-message.ts1.5 KB
  • examples/tasks/task-failure.ts1.1 KB
  • examples/tasks/task-repository.test.ts2.3 KB
  • examples/tasks/task-repository.ts4 KB
  • examples/tasks/to-storage-failure.test.ts7 KB
  • examples/tasks/to-storage-failure.ts5.2 KB
  • examples/tasks/use-add-task-model.test.ts2.7 KB
  • examples/tasks/use-add-task-model.ts1.4 KB
  • references/boundaries-and-catches.md15.6 KB
  • references/checker-rules.md18.6 KB
  • references/global-error-net.md24.6 KB
  • references/never-lose-data.md6.5 KB
  • references/result-and-failures.md15.4 KB
  • references/test-recipes.md9.8 KB
  • scripts/check-error-net.mjs49.1 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.