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
- 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 throwsnew 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. - One Result type, from
src/core/result.ts.{ ok: true, value } | { ok: false, error }withok,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. - A failure is a kebab-case
kindwith typed params, never text or an Error.err({ kind: 'task-title-taken', title }); nevererr('...'),err(new Error()),Result<T, string | Error | unknown>, nor amessagein a failure. Why: a string cannot be switched on exhaustively or translated, and baked-in English breaks an Arabic UI. - Convert at the boundary, once. Adapters, repositories, the ApiClient and parsers catch the vendor's
error and return the boundary's failure family; no
awaitin an adapter escapes outside try/catch orattemptAsync. Why: above the boundary nobody knows SQLite or StoreKit error shapes, and an escaped rejection is dropped silently in release builds. - 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 withoutcauseloses the original stack. - 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. - Handle every failure exhaustively and never drop a Result. Branch on
error.kindwith aswitchor aRecord<F['kind'], ...>table, never anif/elsewhoseelseabsorbs future kinds; a call that returns a Result is never a bare statement (if (x) save(t);included), aforEachcallback's body or anon*arrow body. Why: a new kind becomes a compile error everywhere it must be handled, andno-floating-promisescannot see a droppedResult. - 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.messageis English, technical and can hold user data; a missing key renders a raw id. - 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. - The root route exports an
ErrorBoundarythat 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 itsrecordis 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. - No error path destroys data. No delete, drop, clear, truncate or reset inside a catch, a
.catchhandler 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. - 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
- Choose the mechanism for each failure with the table in
references/result-and-failures.md (section 1): Result,
T | null, throw, boundary or net. - Add the Result module when
src/core/result.tsis missing: copytemplates/src/core/result.tsand 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. - Design one failure family per boundary, imitating examples/tasks/task-failure.ts (sections 4 and 5 of the result reference: kinds, params, layering).
- Convert at the boundary: read references/boundaries-and-catches.md,
imitate examples/tasks/to-storage-failure.ts (and its test on
real
node:sqliteerrors) and examples/tasks/task-repository.ts (and its test); wrap adapter calls withattemptAsync, and a call that can hang with async-safety'swithTimeout((signal) => Promise<Result>, afterMs, signal?)(section 7); a cosmetic adapter follows examples/haptics-adapter.ts. - Handle and present: switch or table on
kind; add a message table like examples/tasks/task-failure-message.ts, put its keys in every catalog, and add the per-locale test from references/test-recipes.md (section 2). Screens call a void intent of the model hook, never a Result function. - Protect the user's data: follow references/never-lose-data.md;
the model keeps input until
ok, as examples/tasks/use-add-task-model.ts and its test show. - 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); makeimport '@/services/error-net/install-error-net';the first app import ofsrc/app/_layout.tsx(or the line after the Intl polyfills inindex.ts); connect the error log right after the composition root creates the services, in the root layout'suseStateinitializer, before any provider renders (examples/root-layout.tsx, examples/connect-error-log.ts). Read 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. - 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 fromuseTheme()(its context defaults to the light theme) and copy fromcreateTranslateNow(onError)insrc/i18n/translate-now.ts(i18n-strings-and-catalogs owns it; it keeps the language the app showed last), wrapped inTextLocaleProviderwith that samelanguageNow()language anddirectionOf(language), so an RTL crash text is laid out right to left (rtl-and-direction'sroot-direction-provider). Putcrash.title,crash.bodyandcrash.restartin every catalog (tscfails on a missing source key). ExportErrorBoundaryfrom the root layout withrecordreachingappErrorNet.reportandrestart: () => 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). - Test: the recipes in references/test-recipes.md; then
npm run typecheck,npx eslint . --max-warnings 0andnpx jest --ci. - Run the checkers from the repo root; look up any rule in
references/checker-rules.md; fix and rerun until both print
RESULT: PASS. - 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.tscomes from the template (any helper added there, with a test); no other Result/Either type and no result library. - Every expected failure is a
kindunion with typed params; noerr('...'),err(new Error()),Result<T, string | Error | unknown>ormessagefield. - Each boundary converts once, records unexpected failures with the original error, and no adapter
awaitescapes. - Every catch maps, records with a fallback, or rethrows with
{ cause };// best-effort:appears only in adapters. - Failures are handled with a
switchor aRecord<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.messagein 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
ErrorBoundaryrecords and offers one way out. -
npm run typecheck,npx eslint . --max-warnings 0andnpx jest --cipass. -
node ${CLAUDE_SKILL_DIR}/scripts/check-error-net.mjs .printsRESULT: 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 .printsRESULT: 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. Returnerr({ 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. Callmodel.add(), which handles it.titles.forEach((title) => repository.add({ id: title, title }))- every failed write vanishes. Loop withfor...ofand branch on each Result.if (failure.kind === 'a') ... else ...- a kind added later silently lands in theelse. Use aswitch(lint demands every case) or aRecord<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 orprevious(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; }aroundnew 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)orrouter.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 fromcreateTranslateNow(src/i18n/translate-now.ts). APP_THEMESimported for a "static" crash background - design-system-structure forbids palette imports, and the theme context's default already is the light theme. UseuseTheme().record: (error) => { console.warn(error); }- the boundary'srecordis the only record of a render error; send it toappErrorNet.report.- A second
ErrorUtils.setGlobalHandler- it replaces the net. Report throughappErrorNet.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| 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 | 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 | 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 | 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 | 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 | 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 | Shape: a failure family and its kinds as data | Workflow step 3 |
| 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 | Shape: the mapper tested on real node:sqlite errors and the iOS and Android message forms | Workflow step 4 |
| 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 | 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 | Shape: the one licensed best-effort swallow | Workflow step 4 |
| 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 | Shape: a model hook that keeps input until ok |
Workflow step 6 |
| examples/tasks/use-add-task-model.test.ts | Shape: the input-survives test | Workflow step 6 |
| 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 | 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 citessrc/core/result.ts.architecture-and-boundaries- ports and adapters, the composition root, theErrorLogPortthe net's sink writes to.async-safety- floating promises, synchronous handlers,.catch(reportError), cancellation.persistence-and-migrations- theSqlDriver, transactions, quarantine in the save-document appendix, and newer-file handling (never touch a file the build cannot use);run-migrationto 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, andsrc/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 behindCrashReporterPort, 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 theno-providersmarker the recipes use.accessibility-as-code-findInaccessiblePressables, the audit the crash screen's test ends with.rtl-and-direction- the crash fallback's ownTextLocaleProvider(languageNow(),directionOf) and itsroot-direction-providerrule.ui-component-library- why the crash screen's coreButtoncarries thepress-outside-hostmarker.data-export-and-restore- backups and all-or-nothing restore.