<!-- Source: README.md -->

# @codenhub/error

Turn whatever your TypeScript code catches into one predictable `AppError`: a message that is safe to show, a translation key, a code to branch on, a flag that says whether retrying is safe, and the original value kept for your logs. You describe the failures you know about once, in a registry; opt-in presets cover common browser, Node.js, and Supabase failures. Result helpers are included for code that prefers returning failures to throwing them.

## Installation

```sh
pnpm add @codenhub/error
```

## Usage

Describe your failures once, when the application starts, then normalize anything you catch:

```ts
import { createAppError, getErrorRegistry } from "@codenhub/error";
import { browserErrorRegistry } from "@codenhub/error/registries/browser";

const registry = getErrorRegistry();

registry.merge(browserErrorRegistry);
registry.codes.add("cart.out_of_stock", {
  message: "One of the items in your cart just sold out. Review your cart, then check out again.",
  messageKey: "error.cart.outOfStock",
});

try {
  await checkout(cart);
} catch (caught) {
  const error = createAppError(caught);

  showToast(error.message);
}
```

A failure nobody registered becomes `type: "unknown"` with the message `"An unexpected error occurred."`, never its raw text.

## Documentation

- [Overview](docs/index.md): what the package does and a five-minute tour.
- [Getting started](docs/getting-started.md): set up a real application step by step.
- [Error boundaries](docs/frameworks.md): React and Svelte fallbacks that show a normalized error.
- [Recipes](docs/recipes.md): HTTP status codes, Supabase, retries, translations, and error reporting.
- [Caveats and limitations](docs/caveats.md): what the package does not do, and the workarounds.
- [API reference](docs/reference/index.md): every export, generated from the source.

## Requirements

- Node.js 24 or newer, or an ES2022-compatible browser, worker, or edge runtime with native `Error` `cause` support.
- ESM. CommonJS can load it with `require()` on runtimes that support requiring ES modules.
- No runtime dependencies, and no access to browser or Node.js globals. The React and Svelte adapters need their framework, an optional peer dependency.

## Notes

- Presets change nothing until you merge them, and the last mapping added for an identifier wins.
- The global registry is shared module state: configure it once at startup, not per request.
- `isRetryable` means the failed call cannot run twice if repeated, not that a retry will likely work. Retry only around the call that failed.
- Built-in `messageKey` values are stable; the package ships English messages only.

## License

This project is licensed under the [Apache-2.0](LICENSE) license.

<!-- Source: docs/index.md -->

# Turn whatever you caught into an error you can use

`@codenhub/error` (ErrorKit) takes the value your code caught, whatever it is, and gives you back one predictable `AppError`: a message you can show a person, a key you can translate, a code you can branch on, a flag that says whether repeating the call is safe, and the original value kept aside for your logs.

## The problem

In TypeScript, a `catch` hands you `unknown`, and in practice that value is anything at all. A failed `fetch` is a `TypeError` whose message depends on the browser. A Supabase call returns an object with a `code` like `"invalid_credentials"`. A database driver throws an error whose `code` is `"23505"`. A library wraps the real failure three levels deep in `cause`. Somebody somewhere throws a plain string.

Each place that catches one of these ends up writing its own little translator, and they drift apart: one screen shows `"duplicate key value violates unique constraint"` to a customer, another shows `"Something went wrong"` for an error that could have said exactly what to do.

## The idea

ErrorKit splits the job in two:

1. **A registry describes the failures you know about**, once, when the application starts. Each entry maps something that identifies a failure (a code, an error name, an exact message, a message prefix, or a regular expression) to the feedback you want people to see.
2. **`createAppError` normalizes anything** against that registry. It looks through the value and the errors wrapped inside it, picks the best match, and returns an `AppError`. When nothing matches, it returns an `AppError` too, with a safe generic message, so the raw text of an unknown failure never reaches the screen.

Ready-made registries, called presets, cover common browser, Node.js, and Supabase failures. They are opt-in: importing one changes nothing until you merge it.

On top of that sit result helpers (`ok`, `err`, `attempt`, `attemptAsync`, and friends), for code that prefers returning failures over throwing them.

## Installation

```sh
pnpm add @codenhub/error
```

The package is ESM and has no runtime dependencies. It has eight entrypoints:

| Import path                           | What it holds                                                                     |
| ------------------------------------- | --------------------------------------------------------------------------------- |
| `@codenhub/error`                     | `createAppError`, the registry functions, the result helpers                      |
| `@codenhub/error/registries/browser`  | The browser and Web API preset                                                    |
| `@codenhub/error/registries/node`     | The Node.js network preset                                                        |
| `@codenhub/error/registries/http`     | The HTTP response status preset                                                   |
| `@codenhub/error/registries/supabase` | The Supabase preset                                                               |
| `@codenhub/error/registries`          | All four presets from one import                                                  |
| `@codenhub/error/react`               | `AppErrorBoundary`, a React error boundary; see [Error boundaries](docs/frameworks.md) |
| `@codenhub/error/svelte`              | `createAppErrorBoundary`, for a Svelte `<svelte:boundary>`                        |

## A five-minute tour

Describe the failures you know about once, when the application starts:

```ts
// errors.ts, imported once at startup
import { getErrorRegistry } from "@codenhub/error";
import { browserErrorRegistry } from "@codenhub/error/registries/browser";

const registry = getErrorRegistry();

registry.merge(browserErrorRegistry);
registry.codes.add("cart.out_of_stock", {
  message: "One of the items in your cart just sold out. Review your cart, then check out again.",
  messageKey: "error.cart.outOfStock",
  source: "my-app.cart",
});
```

Then normalize wherever a failure enters your code:

```ts
import { createAppError } from "@codenhub/error";

try {
  await checkout(cart);
} catch (caught) {
  const error = createAppError(caught);

  showToast(error.message); // Safe to show: it came from the registry or the fallback.
  console.error(error.originalError); // The raw value, for you, not for the reader.
}
```

If `checkout` threw `{ code: "cart.out_of_stock" }`, the toast says the item sold out. If the network dropped, the browser preset recognizes the failed `fetch` and says so. If it was something nobody planned for, the toast says `"An unexpected error occurred."` and the log still has the whole story.

Prefer to return failures instead of throwing them? The same normalization powers the result helpers:

```ts
import { attemptAsync } from "@codenhub/error";

const result = await attemptAsync(() => checkout(cart));

if (!result.ok) {
  showToast(result.error.message);
}
```

## Requirements

- Node.js 24 or newer, or an ES2022-compatible browser, worker, or edge runtime.
- Native `Error` `cause` support, which every runtime above has.
- ESM. CommonJS code can load the package with `require()` on runtimes that support requiring ES modules, such as Node.js 24.

The package never touches browser or Node.js globals, so it runs the same in a browser, on a server, during server-side rendering, in a worker, or on an edge runtime. Importing a preset contacts no service and needs no other package.

## Next steps

Read these in order the first time; each builds on the one before.

- [Getting started](docs/getting-started.md): set up a registry for a real application, step by step.
- [The AppError](docs/app-error.md): every field, what it promises, and how to send an error from a server to a client.
- [Registries](docs/registries.md): how matching works, which match wins, and when to use your own registry instead of the global one.
- [Presets](docs/presets.md): every failure the browser, Node.js, and Supabase presets recognize, with its translation key.
- [Results](docs/results.md): `Result`, `attempt`, and the helpers that chain them.
- [Recipes](docs/recipes.md): `fetch` and HTTP status codes, Supabase, retries, translations, and error reporting.
- [Caveats and limitations](docs/caveats.md): what the package does not do, and how to work around it.
- [API reference](docs/reference/index.md): every export with its signature, generated from the source.

<!-- Source: docs/getting-started.md -->

# Getting started

This page builds the error handling for a small web application that signs people in with Supabase and calls its own API with `fetch`. By the end, every failure the application catches turns into a message a person can act on, and every failure nobody planned for still reaches your logs intact.

## 1. Install the package

```sh
pnpm add @codenhub/error
```

## 2. Create one module that configures errors

Put all registry setup in one module and import it once, before anything else runs. The registry is ordinary module state: whatever you register is visible to every later `createAppError` call in the same JavaScript runtime.

```ts
// src/errors.ts
import { getErrorRegistry } from "@codenhub/error";
import { browserErrorRegistry } from "@codenhub/error/registries/browser";
import { supabaseErrorRegistry } from "@codenhub/error/registries/supabase";

const registry = getErrorRegistry();

// 1. Presets first: they describe failures that come from the platform and from Supabase.
registry.merge(browserErrorRegistry);
registry.merge(supabaseErrorRegistry);

// 2. Your own mappings last, so they win wherever they overlap with a preset.
registry.codes.addList([
  [
    "cart.out_of_stock",
    {
      message: "One of the items in your cart just sold out. Review your cart, then check out again.",
      messageKey: "error.cart.outOfStock",
      source: "my-app.cart",
    },
  ],
  [
    "23505",
    {
      message: "That name is already taken. Choose a different one.",
      messageKey: "error.project.nameTaken",
      source: "my-app.projects",
    },
  ],
]);
```

```ts
// src/main.ts
import "./errors";
// …the rest of your application
```

Order matters. When two mappings use the same identifier, the one added last replaces the other, so the `23505` mapping above replaces the generic "unique violation" message the Supabase preset ships with something specific to this application.

Pick presets for where the code runs: the browser preset for code in a browser, the Node.js preset for code on a server, and the Supabase preset wherever you call Supabase. [Presets](docs/presets.md) lists exactly what each one covers.

## 3. Normalize where failures enter your code

Call `createAppError` at the boundary where a failure reaches code that has to decide what to do with it, usually an event handler, a route handler, or a data loader:

```ts
import { createAppError } from "@codenhub/error";

async function onSubmit(form: FormData) {
  try {
    await saveProject(form);
    showToast("Project saved.");
  } catch (caught) {
    const error = createAppError(caught);

    showToast(error.message);
    reportIfUnexpected(error);
  }
}
```

You do not need to unwrap anything first. `createAppError` looks inside the value for wrapped errors, in `cause`, `originalError`, `error`, `err`, `inner`, `innerError`, and the first ten entries of an `errors` list, so a `23505` buried two `cause`s deep still resolves to "That name is already taken."

## 4. Decide what to show

Every `AppError` has a `message`, and it is always safe to show:

- When a mapping matched, it is the message you or the preset registered.
- When nothing matched, it is `"An unexpected error occurred."`, never the raw text of the failure. Raw messages often contain table names, stack details, or user data, so ErrorKit refuses to surface them.

You can replace that generic text where you know more about the context:

```ts
const error = createAppError(caught, {
  fallbackMessage: "We couldn't save your project. Try again in a moment.",
});
```

## 5. Decide what to report

`error.type` says how the error was matched:

| `type`        | Meaning                                                                     |
| ------------- | --------------------------------------------------------------------------- |
| `"known"`     | A code, name, exact message, or message prefix matched.                     |
| `"heuristic"` | Only a regular expression matched, such as the browser's "Failed to fetch". |
| `"unknown"`   | Nothing matched. The message is the fallback.                               |

An `"unknown"` error is the one you most want in your error tracker, because it is a failure nobody described yet. The raw value is on `error.originalError`, and also on `error.cause`, which most error trackers already follow:

```ts
import type { AppError } from "@codenhub/error";

function reportIfUnexpected(error: AppError) {
  if (error.type === "unknown") {
    errorTracker.captureException(error.originalError);
  }
}
```

`type` says how the error matched, not how serious it is. A missing database table is `"known"` once its code is registered, and it is still a bug. [Recipes](docs/recipes.md#report-to-an-error-tracker) shows how to report those too.

## Where to go next

- [The AppError](docs/app-error.md) explains every field, including `code` for branching and `isRetryable` for retries.
- [Registries](docs/registries.md) explains which match wins when several could.
- [Recipes](docs/recipes.md) covers `fetch` status codes, Supabase calls, retries, and translations.

<!-- Source: docs/app-error.md -->

# The AppError

Everything in this package ends in an `AppError`. This page walks through its fields, the rules that keep it stable as it passes through layers of your code, and how to send one across a network or worker boundary.

## The fields

```ts
const error = createAppError({ code: "invalid_credentials" }, { registry: supabaseErrorRegistry });

error.name; // "AppError"
error.message; // "We couldn't sign you in. The email or password is incorrect. Check them and try again."
error.type; // "known"
error.code; // "invalid_credentials"
error.messageKey; // "error.supabase.auth.invalidCredentials"
error.source; // "supabase.auth"
error.isRetryable; // false
error.details; // null
error.originalError; // { code: "invalid_credentials" }
```

### `message`

Text that is safe to show to the person using the application. It is the message of the mapping that matched, or the fallback message when nothing matched. It is never the raw message of the value you passed in.

### `type`

How the error was matched: `"known"` for a code, name, exact message, or prefix, `"heuristic"` for a regular expression, and `"unknown"` when nothing matched. It says nothing about severity: a missing table is `"known"` once its code is registered, and an offline user is `"heuristic"` because browsers only report that in a message.

### `code`

The identifier that classified the error, for branching on a specific failure:

```ts
if (error.code === "email_not_confirmed") {
  redirectTo("/confirm-email");
}
```

It holds the registered code that matched, or the registered name when a name matched instead. A message, prefix, or pattern match has no code, so `code` is `null` there, and also for unknown errors.

When the same failure can arrive both ways, branch on `messageKey` instead. A stale code-split chunk is a good example: webpack reports it by name, so `code` is `"ChunkLoadError"`, but a browser reports a failed `import()` only in its message, so `code` is `null`. Both resolve to `messageKey: "error.browser.moduleLoadFailed"`.

### `messageKey`

A translation key from the matched mapping, or `null`. The built-in presets give every mapping one, and they are stable: a key keeps its meaning across releases even when the English message is reworded. ErrorKit does not ship translations; [Recipes](docs/recipes.md#translate-messages) shows how to plug in your own.

### `source`

A label for where the failure came from, such as `"supabase.auth"`, `"browser.network"`, or whatever you register for your own mappings. `null` when the mapping has none or nothing matched. It is useful for grouping in logs and dashboards.

### `isRetryable`

Whether the call that failed can be repeated as it is, without asking the person to do anything and without the risk that it runs twice. It is `false` unless the matched mapping says otherwise.

This flag is deliberately strict. A failure that may have happened after the server received the request is not retryable, however likely a second attempt is to succeed, because repeating it could charge a card twice or send an email twice. In the built-in presets, only failures that happen before a connection is made are retryable, such as `ECONNREFUSED` in the Node.js preset.

Two consequences are easy to miss:

- **The flag describes the call that failed, which can sit several wrappers down.** A `publish()` function that uploads a file and then fails to save a record with `ECONNREFUSED` produces a retryable error. That is true of the save, not of `publish()`: repeating `publish()` uploads the file again. Retry on the flag only where your code made the failing call itself.
- **The flag is about an error your own runtime raised.** A server that forwards its upstream failure as `{ "code": "ECONNREFUSED" }` in a response body matches the same mapping, but that request did reach the server. Classify response bodies against a registry that holds that service's codes and no network preset; see [Recipes](docs/recipes.md#classify-http-responses).

### `details`

Data your code attached on purpose, such as the form field that failed, or `null`. Pass it with the `details` option of `createAppError`, `err`, `attempt`, or `attemptAsync`:

```ts
getErrorRegistry().codes.add("signup.email_taken", { message: "That email is already registered." });

const result = err({ code: "signup.email_taken" }, { details: { field: "email" } });

result.error.details; // { field: "email" }
```

It holds JSON values only: strings, finite numbers, booleans, `null`, lists, and plain objects, nested at most eight levels deep. A key whose value is `undefined` is dropped, as `JSON.stringify` drops it, so an optional field does not have to be checked first. Anything else, such as a `Date` or an `undefined` list entry, throws `TypeError`. It is copied and frozen, so changing the object you passed changes nothing.

Unlike the raw error, `details` is part of `toJSON()`, so it reaches a client. It is never read from the value being normalized, only from the option, so nothing gets into it that your code did not put there. Keep anything secret out of it.

When an error passes through several layers, each one can add keys. A key the error already has keeps its value, so a layer never overwrites what an inner one recorded.

### `originalError` and `cause`

The value you passed to `createAppError`, untouched, for logs and debugging. It is also the error's standard `cause`, so error trackers and Node.js's `console.error` show it without extra work. It is non-enumerable and left out of `JSON.stringify`, so raw details do not leak into a response by accident.

## An AppError cannot be changed

None of an `AppError`'s own fields can be changed or deleted; trying throws in strict mode. The `stack` is the one exception: it describes where the error was made, not what it means, and libraries that rethrow an error they caught call `Error.captureStackTrace` on it, which would throw on a locked stack. The error stays open to new properties, though, because some frameworks add their own to every error they catch: Koa sets `status`, and hapi's `Boom.boomify` sets `isBoom`. A fully frozen error would make those frameworks throw inside their own error handler.

`createAppError` and `appErrorFromJSON` are the only ways to make one. Use `isAppError(value)` to check for one; it recognizes only errors made by this copy of the package, not objects that merely look similar.

## Normalizing twice

Code often passes an error through several layers, and each one may call `createAppError` again. The rule is that the first layer to classify a failure keeps its wording:

```ts
const first = createAppError(caught, { registry: billingRegistry });
const again = createAppError(first, { fallbackMessage: "Checkout failed." });

again === first; // true, when billingRegistry matched `caught`
```

Passing an `AppError` returns the same object, and `fallbackMessage` and `maxDepth` never change its classification. A `fallbackMessage` words an `"unknown"` error that still carries the default message, because no layer before it chose any wording; once one has, its message stays:

```ts
const inner = await attemptAsync(() => db.loadProject(id)); // no fallback here
const outer = await attemptAsync(() => unwrap(inner), { fallbackMessage: "Couldn't load the project." });

outer.ok || outer.error.message; // "Couldn't load the project."
```

The one exception to keeping the classification is when you pass a `registry`: then the raw value inside is classified again, and the result can only get better. A pattern match replaces an `"unknown"` error, and a known match replaces anything it differs from. Everything else returns the same object.

An `AppError` wrapped in another error, as in `new Error("checkout failed", { cause: appError })`, follows the same rule: its classification is used as it stands unless you pass a `registry`.

## Sending an error across a boundary

An `AppError` serializes to exactly eight fields, on every JavaScript engine:

```ts
JSON.stringify(error);
// {"name":"AppError","message":"…","type":"known","code":"23505","messageKey":"…","source":"…","isRetryable":false,"details":null}
```

`originalError` and `cause` are left out on purpose: they can hold secrets, personal data, or cyclic objects.

On the receiving side, `appErrorFromJSON` rebuilds an `AppError` from that shape:

```ts
// Server
return Response.json({ error: error.toJSON() }, { status: 409 });

// Client
const body = await response.json();
const error = appErrorFromJSON(body.error);
```

It consults no registry: the rebuilt error carries the message the sender chose, so use it only on data from a source you trust to word your errors, such as your own server. `isRetryable` always comes back `false`, because the sender's flag described the sender's own call, not the request that carried the error back. A value that is not exactly the serialized shape is normalized as raw input, as `createAppError` would.

To pass an error to a worker, through `postMessage`, `structuredClone`, or IndexedDB, send `error.toJSON()` too. A structured clone copies an `AppError` as a plain `Error` without its fields, and on some engines carries the raw `cause` along with it.

## Options

`createAppError(error, options)` accepts:

| Option            | Default                           | Effect                                                        |
| ----------------- | --------------------------------- | ------------------------------------------------------------- |
| `fallbackMessage` | `"An unexpected error occurred."` | The message when nothing matches. Must be a non-empty string. |
| `registry`        | The global registry               | Where to look for matches. See [Registries](docs/registries.md).   |
| `maxDepth`        | `3`                               | How deep to look inside wrapped errors, from `0` to `3`.      |
| `details`         | None                              | Data to attach as `details`. See [`details`](#details).       |

At the default depth, a `cause` chain alone is followed further, to a depth of `8`, because a failure wrapped once per layer of an application often sits deeper than three. A lower `maxDepth` stops every field, `cause` included, at that depth.

The default message is exported as `DEFAULT_APP_ERROR_MESSAGE`. An invalid option is a programming mistake, so it throws `TypeError` before any work starts. Ordinary input never throws: `createAppError` accepts any value, including `null`, numbers, and objects whose properties throw when read.

<!-- Source: docs/registries.md -->

# Registries

A registry is where you describe the failures you know about. This page explains how to fill one, how `createAppError` searches it, and how to choose between the global registry and registries of your own.

## Feedback: what a match produces

Every entry maps an identifier to feedback:

```ts
registry.codes.add("E_RATE_LIMIT", {
  message: "We've received too many requests. Wait a few minutes, then try again.",
  messageKey: "error.myApp.api.rateLimit",
  source: "my-app.api",
  isRetryable: false,
});
```

| Field         | Required | Meaning                                                                                                                 |
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `message`     | Yes      | The text people see. Write what happened and what to do next.                                                           |
| `messageKey`  | No       | A translation key. Any non-empty string, so you can reuse the keys your catalog already has.                            |
| `source`      | No       | A label for where the failure came from. Any non-empty string.                                                          |
| `isRetryable` | No       | Whether the failing call is safe to repeat as it is. Defaults to `false`; see [The AppError](docs/app-error.md#isretryable). |

Entries are checked and copied when you add them. An empty `message`, or a field of the wrong type, throws `TypeError` right there, not later when an error is being handled.

## The five buckets

A registry has five buckets, one for each way a failure can be recognized:

| Bucket     | Matches                                    | Example identifier             | Result type   |
| ---------- | ------------------------------------------ | ------------------------------ | ------------- |
| `codes`    | The error's `code` property                | `"23505"`, `"ECONNREFUSED"`    | `"known"`     |
| `names`    | The error's `name` property                | `"AbortError"`                 | `"known"`     |
| `messages` | The whole `message`                        | `"Upload failed"`              | `"known"`     |
| `prefixes` | The start of the `message`                 | `"Quota exceeded for bucket"`  | `"known"`     |
| `patterns` | A regular expression against the `message` | `/^request to .+ timed out$/i` | `"heuristic"` |

Prefer the buckets near the top. A code or name is chosen by whoever wrote the failing code and rarely changes; a message is prose, and the next library release can reword it.

Each bucket has `add(identifier, feedback)`, `addList(entries)`, `delete(identifier)`, `clear()`, and `values()`. The `codes`, `names`, and `messages` buckets also have `get(identifier)`. `addList` checks the whole list before adding anything, so a mistake in the fifth entry leaves the bucket unchanged.

### How identifiers are compared

- **Codes and names** are trimmed of surrounding whitespace and otherwise compared exactly, including case and punctuation. A numeric `code`, such as an HTTP status, is compared as text: `{ code: 404 }` matches a registered `"404"`.
- **Messages and prefixes** are trimmed, and every trailing `.`, `!`, `?`, and space is removed, so `"Upload failed"`, `"Upload failed."`, and `"Upload failed!! "` are the same identifier. Comparison is case-sensitive: `"upload failed"` does not match.
- **Prefixes** match when the message starts with the prefix. When several match, the longest wins.
- **Patterns** run against the raw message, in the order they were added; the first that matches wins. The `g` and `y` flags are removed when a pattern is added, because they make `test()` remember its last position and give different answers on the same text. Use the `i` flag for case-insensitive matching.

A `DOMException` is matched by its name only. It also carries an old numeric `code`, `20` for `AbortError`, which would otherwise match unrelated codes.

Adding an identifier that already exists replaces its feedback. For patterns, "the same" means the same source and flags.

## Which match wins

`createAppError` collects the value you passed and every error wrapped inside it, starting with the outermost and working inward. It then picks one result in this order:

1. A **known** match anywhere: a code, name, exact message, or prefix, or an `AppError` that is already `"known"`. The outermost one wins, with one exception: when you pass a `registry`, a known match from that registry wins over a known `AppError` found inside the value, however deep the match is, because passing a registry asks for its wording.
2. Otherwise, a **heuristic** match anywhere: a pattern, or an `AppError` that is already `"heuristic"`.
3. Otherwise, any `AppError` found inside the value.
4. Otherwise, an `"unknown"` error with the fallback message.

Within one wrapped value, the order is code, then name, then exact message, then prefix, then pattern.

The first rule is what makes deeply wrapped failures work. A `fetch` that fails on Node.js throws `TypeError: fetch failed`, which matches a pattern, but its `cause` carries `code: "ECONNREFUSED"`, which matches a code. The deeper known match wins over the shallower heuristic one, so you get the more specific message.

Wrapped errors are found in `cause`, `originalError`, `error`, `err`, `inner`, and `innerError`, and in the first ten entries of an `errors` list, as on `AggregateError`. The search goes three levels deep, and a `cause` chain continues to eight; see [The AppError](docs/app-error.md#options) for `maxDepth`. Objects already visited are skipped, so a cycle cannot loop forever.

## The global registry, or your own

`getErrorRegistry()` returns the global registry, which starts empty and is what `createAppError` uses when you pass no `registry`. For most applications, one global registry configured at startup is all you need.

Create your own with `createErrorRegistry()` when one set of mappings should not apply everywhere:

```ts
import { createAppError, createErrorRegistry } from "@codenhub/error";

const billingRegistry = createErrorRegistry();
billingRegistry.codes.add("card_declined", {
  message: "Your card was declined. Try a different card.",
  source: "my-app.billing",
});

const error = createAppError(caught, { registry: billingRegistry });
```

Reach for a separate registry when:

- **Two services use the same code for different things.** The `codes` bucket is one namespace, so if your payment provider and your database both use `"P0001"`, whichever was added last wins. Give each service its own registry and classify its errors against it.
- **A preset's names are too broad for some code.** The `names` bucket matches any value with that name. With the browser preset merged, a library's own `TimeoutError` class is classified as the browser's.
- **You classify data from somewhere else.** Response bodies from another service should be classified against that service's codes, not against network presets meant for your own runtime.

`createErrorRegistry(presets)` starts a registry from a list of others, merged in order:

```ts
import { createErrorRegistry } from "@codenhub/error";
import { nodeErrorRegistry } from "@codenhub/error/registries/node";

const apiRegistry = createErrorRegistry([nodeErrorRegistry, myApiRegistry]);
```

`setErrorRegistry(registry)` replaces the global registry outright. It is mostly useful in tests, to start each test from a known state.

## Combining and freezing

`registry.merge(other)` copies every entry of `other` into `registry`, replacing entries with the same identifier. It checks the whole source first, so a bad entry leaves the target unchanged. `registry.clear()` empties every bucket.

`freezeRegistry(registry)` returns a read-only snapshot. It has only the reading methods, `get` and `values`, even through reflection, and later changes to the original do not reach it. The built-in presets are frozen snapshots, and freezing is the way to share a set of mappings between packages or teams without anyone changing it by accident:

```ts
import { createErrorRegistry, freezeRegistry } from "@codenhub/error";

const registry = createErrorRegistry();
registry.codes.add("cart.out_of_stock", { message: "One of the items in your cart just sold out." });

export const cartErrors = freezeRegistry(registry);
```

Everything a bucket returns is frozen: `values()` returns the same frozen list until the bucket changes, and its entries, feedback objects, and regular expressions are frozen too. Spread one into a new object when you need a copy you can change.

## Writing patterns

A pattern runs against the message of every failure that reaches `createAppError`, and messages can come from anywhere, including someone trying to slow your server down. A pattern that backtracks heavily, such as `/(a+)+$/`, can stall on a crafted message. Keep patterns simple: anchor them with `^` and `$`, avoid nested repetition, and use a code, name, message, or prefix whenever one exists.

Anchoring also stops a pattern from matching too much. `/load failed/i` matches `"Config load failed"`; `/^load failed\.?$/i` matches only the browser's own message.

<!-- Source: docs/presets.md -->

# Presets

A preset is a ready-made, read-only registry for failures that come from a platform or a service rather than from your own code. There are four: browser, Node.js, HTTP, and Supabase. Each is a separate import, so an application only loads the ones it uses, and importing one changes nothing until you merge it:

```ts
import { getErrorRegistry } from "@codenhub/error";
import { browserErrorRegistry } from "@codenhub/error/registries/browser";

getErrorRegistry().merge(browserErrorRegistry);
```

Each preset also exports its raw mappings, frozen, for when you want to build something of your own from part of a preset:

| Import path                           | Registry                | Raw mappings                                            |
| ------------------------------------- | ----------------------- | ------------------------------------------------------- |
| `@codenhub/error/registries/browser`  | `browserErrorRegistry`  | `browserErrorNames`, `browserErrorPatterns`             |
| `@codenhub/error/registries/node`     | `nodeErrorRegistry`     | `nodeErrorCodes`, `nodeErrorNames`, `nodeErrorPatterns` |
| `@codenhub/error/registries/http`     | `httpErrorRegistry`     | `httpErrorCodes`                                        |
| `@codenhub/error/registries/supabase` | `supabaseErrorRegistry` | `supabaseErrorCodes`, `supabaseErrorNames`              |
| `@codenhub/error/registries`          | All four                | All eight                                               |

## How the messages are written

Every preset message says what happened and what the reader can do next, in plain words, and claims only what the matched identifier proves. None of them assumes your application has a password reset page, a support chat, or any other feature; where the reader can do nothing else, a message says to contact support without naming how.

Failures only a developer can fix, such as a missing table or column, share one message that says the problem is not the reader's doing and names no internals: "Something went wrong on our side, and it isn't caused by anything you did. Contact support if it keeps happening." The details stay on `originalError` for your logs.

When your application can say something more useful, register your own mapping for the same identifier after merging the preset; the later one wins.

Every mapping has a `messageKey`. The keys are stable, and each key always carries the same message, so a translation catalog can rely on them. Several identifiers can share one key when they mean the same thing to the reader. ErrorKit does not ship translations; see [Recipes](docs/recipes.md#translate-messages).

## Browser

For code that runs in a browser. It recognizes the `DOMException` names the Web APIs raise, a failed `fetch`, and a code-split chunk that fails to load.

| Name                 | `messageKey`                         | Retryable |
| -------------------- | ------------------------------------ | --------- |
| `AbortError`         | `error.browser.abort`                | No        |
| `QuotaExceededError` | `error.browser.storageQuotaExceeded` | No        |
| `NotAllowedError`    | `error.browser.permissionDenied`     | No        |
| `NotFoundError`      | `error.browser.notFound`             | No        |
| `SecurityError`      | `error.browser.security`             | No        |
| `TimeoutError`       | `error.browser.timeout`              | No        |
| `NotSupportedError`  | `error.browser.notSupported`         | No        |
| `InvalidStateError`  | `error.browser.invalidState`         | No        |
| `NetworkError`       | `error.browser.network`              | No        |
| `NotReadableError`   | `error.browser.notReadable`          | No        |
| `EncodingError`      | `error.browser.encoding`             | No        |
| `ChunkLoadError`     | `error.browser.moduleLoadFailed`     | No        |

Its patterns match whole messages only, so text that merely contains the same words is not classified:

| Message                                                                                                                                                                                  | `messageKey`                     | Retryable |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------- |
| A failed `fetch`: `Failed to fetch` (Chromium), `NetworkError when attempting to fetch resource.` (Firefox), `Load failed` (Safari)                                                      | `error.browser.requestFailed`    | No        |
| A failed `import()`: `Failed to fetch dynamically imported module: …` (Chromium), `error loading dynamically imported module: …` (Firefox), `Importing a module script failed.` (Safari) | `error.browser.moduleLoadFailed` | No        |

Notes:

- **Nothing is retryable.** A failed `fetch` looks the same whether the connection never opened or the response was lost after the server acted on the request, as a CORS block or a dropped connection does. A `TimeoutError` from `AbortSignal.timeout()` can fire after the server received the request. The messages ask the reader to check whether the action went through before trying again.
- **A failed chunk is usually a tab left open across a deploy**: the page asks for a file the new deploy removed. The message tells the reader to reload. `ChunkLoadError` is what webpack throws; Vite and other bundlers that use native `import()` produce the browser's message.
- **`AbortError` is often deliberate.** It also fires when your own code cancels a request, such as an effect cleanup or a newer search replacing an older one. See [Caveats](docs/caveats.md#cancelled-requests-are-errors-too).

## Node.js

For server code. It recognizes network failures from sockets, DNS lookups, and the built-in `fetch`, which puts the real failure on the `cause` of its `TypeError: fetch failed`, and the errors an `AbortSignal` raises.

| Code                      | `messageKey`                             | Retryable |
| ------------------------- | ---------------------------------------- | --------- |
| `ECONNREFUSED`            | `error.node.network.connectionRefused`   | Yes       |
| `ENOTFOUND`               | `error.node.network.addressNotFound`     | Yes       |
| `UND_ERR_CONNECT_TIMEOUT` | `error.node.network.connectionTimeout`   | Yes       |
| `EAI_AGAIN`               | `error.node.network.addressLookupFailed` | Yes       |
| `EHOSTUNREACH`            | `error.node.network.unreachable`         | Yes       |
| `ENETUNREACH`             | `error.node.network.unreachable`         | Yes       |
| `ECONNRESET`              | `error.node.network.connectionReset`     | No        |
| `UND_ERR_SOCKET`          | `error.node.network.connectionClosed`    | No        |
| `EPIPE`                   | `error.node.network.connectionClosed`    | No        |
| `ETIMEDOUT`               | `error.node.network.responseTimeout`     | No        |
| `UND_ERR_HEADERS_TIMEOUT` | `error.node.network.responseTimeout`     | No        |
| `UND_ERR_BODY_TIMEOUT`    | `error.node.network.responseTimeout`     | No        |

| Name           | `messageKey`                         | Retryable |
| -------------- | ------------------------------------ | --------- |
| `TimeoutError` | `error.node.network.responseTimeout` | No        |
| `AbortError`   | `error.node.abort`                   | No        |

| Message                             | `messageKey`                       | Retryable |
| ----------------------------------- | ---------------------------------- | --------- |
| `fetch failed`, with no known cause | `error.node.network.requestFailed` | No        |

Notes:

- **The messages speak about "a service this depends on".** On a server, the failing call is your server calling something else, so the person reading the message is not the one whose connection failed, and the messages do not ask them to check it.
- **Only failures before a connection is made are retryable**: refused, an address that did not resolve, an unreachable host, a connection timeout. A reset, a closed socket, or a response timeout can follow a request the other side already acted on. `ETIMEDOUT` is not retryable either, because Node.js raises it both for a connection attempt and for a socket that already carried the request.
- **Not covered:** filesystem codes such as `ENOENT`, because the right message depends on what the application was doing, and TLS certificate codes, which only an operator can fix.
- **`TimeoutError` and `AbortError` are also browser preset names.** They are the `DOMException` names an `AbortSignal` raises, as in `fetch(url, { signal: AbortSignal.timeout(5000) })`. Server code should use this preset for them: the browser preset's `TimeoutError` message tells the reader to check their internet connection. When you merge both, the one merged last wins.
- The codes were observed on Node.js 24. Other server runtimes, such as Deno or Bun, may report network failures differently.

## HTTP

For the status of a failed response. `fetch` resolves on a `404` or a `500`, so a status is not something `createAppError` finds by itself: pass it as the `code`, and pass this preset as the `registry` for that call rather than merging it into the global one. Statuses are short numbers that only mean something for a response, and could collide with another service's codes.

```ts
import { err } from "@codenhub/error";
import { httpErrorRegistry } from "@codenhub/error/registries/http";

if (!response.ok) {
  return err({ code: response.status }, { registry: httpErrorRegistry });
}
```

| Code  | `messageKey`                 | Retryable |
| ----- | ---------------------------- | --------- |
| `400` | `error.http.badRequest`      | No        |
| `401` | `error.http.unauthorized`    | No        |
| `403` | `error.http.forbidden`       | No        |
| `404` | `error.http.notFound`        | No        |
| `409` | `error.http.conflict`        | No        |
| `413` | `error.http.payloadTooLarge` | No        |
| `422` | `error.http.unprocessable`   | No        |
| `429` | `error.http.rateLimited`     | No        |
| `500` | `error.http.serverError`     | No        |
| `502` | `error.http.badGateway`      | No        |
| `503` | `error.http.unavailable`     | No        |
| `504` | `error.http.gatewayTimeout`  | No        |

Notes:

- **Nothing is retryable.** A status is the server's answer, so the request reached it; even a `503` or `504` can follow a request the server, or a service behind it, already acted on. A `429` needs a wait the flag cannot express.
- **The messages are generic.** A `401` from a sign-in form means a wrong password, and a `404` from a search means no results. Where a status means something specific, register your own mapping for that call in a registry of its own.
- **`500` carries the shared developer-fault message**, and should reach your error tracker; see [Recipes](docs/recipes.md#report-to-an-error-tracker).
- **Not covered:** `1xx`, `2xx`, and `3xx` statuses, which are not failures, and the rarer `4xx` and `5xx` statuses. They resolve to `"unknown"`.
- **When your server sends `error.toJSON()`**, rebuild that error with `appErrorFromJSON` instead; it says more than the status.

## Supabase

For code that calls Supabase. It recognizes the Auth error codes a person using your application can run into and do something about, a few PostgreSQL error codes, and the error names the Supabase client raises.

Supabase Auth codes:

| Code                                | `messageKey`                                         | Retryable |
| ----------------------------------- | ---------------------------------------------------- | --------- |
| `invalid_credentials`               | `error.supabase.auth.invalidCredentials`             | No        |
| `email_not_confirmed`               | `error.supabase.auth.emailNotConfirmed`              | No        |
| `phone_not_confirmed`               | `error.supabase.auth.phoneNotConfirmed`              | No        |
| `user_already_exists`               | `error.supabase.auth.userAlreadyExists`              | No        |
| `email_exists`                      | `error.supabase.auth.emailExists`                    | No        |
| `phone_exists`                      | `error.supabase.auth.phoneExists`                    | No        |
| `signup_disabled`                   | `error.supabase.auth.signupDisabled`                 | No        |
| `provider_disabled`                 | `error.supabase.auth.methodUnavailable`              | No        |
| `email_provider_disabled`           | `error.supabase.auth.methodUnavailable`              | No        |
| `phone_provider_disabled`           | `error.supabase.auth.methodUnavailable`              | No        |
| `otp_disabled`                      | `error.supabase.auth.methodUnavailable`              | No        |
| `provider_email_needs_verification` | `error.supabase.auth.providerEmailNeedsVerification` | No        |
| `otp_expired`                       | `error.supabase.auth.otpExpired`                     | No        |
| `invite_not_found`                  | `error.supabase.auth.inviteNotFound`                 | No        |
| `weak_password`                     | `error.supabase.auth.weakPassword`                   | No        |
| `same_password`                     | `error.supabase.auth.samePassword`                   | No        |
| `email_address_invalid`             | `error.supabase.auth.emailAddressInvalid`            | No        |
| `validation_failed`                 | `error.supabase.auth.validationFailed`               | No        |
| `captcha_failed`                    | `error.supabase.auth.captchaFailed`                  | No        |
| `user_banned`                       | `error.supabase.auth.userBanned`                     | No        |
| `user_not_found`                    | `error.supabase.auth.userNotFound`                   | No        |
| `session_expired`                   | `error.supabase.auth.sessionEnded`                   | No        |
| `session_not_found`                 | `error.supabase.auth.sessionEnded`                   | No        |
| `refresh_token_not_found`           | `error.supabase.auth.sessionEnded`                   | No        |
| `refresh_token_already_used`        | `error.supabase.auth.sessionEnded`                   | No        |
| `reauthentication_needed`           | `error.supabase.auth.reauthenticationNeeded`         | No        |
| `reauthentication_not_valid`        | `error.supabase.auth.reauthenticationNotValid`       | No        |
| `mfa_verification_failed`           | `error.supabase.auth.mfaVerificationFailed`          | No        |
| `mfa_challenge_expired`             | `error.supabase.auth.mfaChallengeExpired`            | No        |
| `insufficient_aal`                  | `error.supabase.auth.insufficientAal`                | No        |
| `flow_state_expired`                | `error.supabase.auth.signInInterrupted`              | No        |
| `flow_state_not_found`              | `error.supabase.auth.signInInterrupted`              | No        |
| `bad_oauth_callback`                | `error.supabase.auth.signInInterrupted`              | No        |
| `pkce_code_verifier_not_found`      | `error.supabase.auth.signInInterrupted`              | No        |
| `bad_oauth_state`                   | `error.supabase.auth.badOAuthState`                  | No        |
| `sms_send_failed`                   | `error.supabase.auth.smsSendFailed`                  | No        |
| `over_sms_send_rate_limit`          | `error.supabase.auth.overSmsSendRateLimit`           | No        |
| `over_email_send_rate_limit`        | `error.supabase.auth.overEmailSendRateLimit`         | No        |
| `over_request_rate_limit`           | `error.supabase.auth.overRequestRateLimit`           | No        |
| `request_timeout`                   | `error.supabase.auth.requestTimeout`                 | No        |
| `unexpected_failure`                | `error.supabase.auth.unexpectedFailure`              | No        |

PostgreSQL codes, as returned by Supabase's database API:

| Code    | Meaning                          | `messageKey`                                  | Retryable |
| ------- | -------------------------------- | --------------------------------------------- | --------- |
| `23502` | A required value is missing      | `error.supabase.database.notNullViolation`    | No        |
| `23503` | Linked data is missing or in use | `error.supabase.database.foreignKeyViolation` | No        |
| `23505` | A unique value is taken          | `error.supabase.database.uniqueViolation`     | No        |
| `42P01` | A table does not exist           | `error.supabase.database.undefinedTable`      | No        |
| `42703` | A column does not exist          | `error.supabase.database.undefinedColumn`     | No        |
| `57014` | A query took too long            | `error.supabase.database.timeout`             | No        |

Error names raised by the Supabase client:

| Name                      | `messageKey`                       | Retryable |
| ------------------------- | ---------------------------------- | --------- |
| `AuthSessionMissingError` | `error.supabase.auth.sessionEnded` | No        |
| `FunctionsHttpError`      | `error.supabase.functions.http`    | No        |
| `FunctionsRelayError`     | `error.supabase.functions.relay`   | No        |
| `FunctionsFetchError`     | `error.supabase.functions.fetch`   | No        |

Notes:

- **Nothing is retryable.** Rate-limit errors need a wait the flag cannot express, so retrying on them at once only counts against the limit again; their messages tell the reader how long to wait. Edge Function errors arrive after the function may have run.
- **`AuthSessionMissingError` has no code.** It is what most Auth calls return when nobody is signed in, and it is classified as an ended session. `pkce_code_verifier_not_found` is raised by the client itself, when a sign-in link is opened in a different browser from the one that asked for it.
- **Not covered:** Auth codes only a developer can resolve, such as `bad_jwt` and the `hook_*` and `saml_*` families. They resolve to `"unknown"`, which is what you want reported.
- **`23505` is generic on purpose.** "A unique value is taken" cannot say which value. Register your own `23505` mapping where you know, or classify a specific table's errors against its own registry.

<!-- Source: docs/results.md -->

# Results

A thrown error is invisible in a function's type: `loadProject(id): Promise<Project>` says nothing about the ways it can fail, and every caller has to remember its own `try`/`catch`. A `Result` puts the failure in the type, so the compiler reminds the caller to handle it.

```ts
type Result<T> = Ok<T> | Err;

interface Ok<T> {
  readonly ok: true;
  readonly value: T;
}

interface Err {
  readonly ok: false;
  readonly error: AppError;
}
```

The failure side is always an `AppError`, normalized through the same registry as `createAppError`, so a `Result` is the same error handling in a different shape.

## Making results

`ok(value)` wraps a success, and `ok()` makes an `Ok<void>` for operations that return nothing. `err(error, options?)` normalizes a failure, exactly as `createAppError(error, options)` would:

```ts
import { err, getErrorRegistry, ok, type Result } from "@codenhub/error";

getErrorRegistry().codes.add("project.name_missing", {
  message: "Give the project a name, then save again.",
  source: "my-app.projects",
});

function validateName(name: string): Result<string> {
  const trimmed = name.trim();
  return trimmed === "" ? err({ code: "project.name_missing" }) : ok(trimmed);
}
```

Both return frozen objects.

Raise your own failures by code, as above, rather than by message. A string passed to `err` is matched as message text against the `messages`, `prefixes`, and `patterns` buckets, never against `codes`, and an unmatched string does not become the message: `err("Name is required")` gives you `"An unexpected error occurred."` unless that exact message is registered. That is the rule that keeps raw text off the screen, applied to your own strings too.

## Reading results

Check `ok` and TypeScript narrows the rest:

```ts
const name = validateName(input);

if (!name.ok) {
  showFieldError(name.error.message);
  return;
}

save(name.value); // name.value is a string here
```

## Wrapping code that throws

Most code you call throws instead of returning results: `JSON.parse`, `fetch`, database clients. `attempt` and `attemptAsync` run a callback and turn anything it throws into an `Err`:

```ts
import { attempt, attemptAsync } from "@codenhub/error";

const config = attempt(() => JSON.parse(text) as Config);
const project = await attemptAsync(() => api.loadProject(id));
```

Both take the same options as `createAppError` as their second argument, such as a `fallbackMessage` for this call or a `registry`.

A few rules keep them honest:

- `attempt` is for synchronous callbacks only. A callback that returns a promise is a type error, and at runtime, where the type checker cannot see it (a callback typed `any`, or plain JavaScript), `attempt` throws `TypeError` instead of returning an `Ok` that holds a promise whose rejection nobody would catch. Use `attemptAsync` for anything asynchronous.
- That type check has to know what the callback returns, so inside a generic function a callback returning a value of the type parameter is a type error too: `attempt(() => JSON.parse(text) as T)` does not compile. Pass the type argument instead of casting the value, `attempt<T>(() => JSON.parse(text))`. For a callback that returns a `T` it got from elsewhere, cast its result to `never`, `attempt<T>(() => read() as never)`; the runtime check still rejects a promise.
- `attemptAsync` never rejects for something the callback did. Its promise always resolves to a `Result`.
- Both check their arguments before running the callback. A callback that is not a function, or an invalid option, throws `TypeError` (for `attemptAsync`, rejects with it) without running anything.

## Chaining results

Most of the remaining helpers work on the success side and pass an `Err` through untouched; `orElse` and `orElseAsync` work on the failure side:

| Helper                           | What it does                                                                                      |
| -------------------------------- | ------------------------------------------------------------------------------------------------- |
| `map(result, mapper)`            | Transforms an `Ok` value. Returns the `Err` unchanged.                                            |
| `mapAsync(result, mapper)`       | Like `map`, with a mapper that may return a promise. Always returns a promise.                    |
| `andThen(result, mapper)`        | Runs a mapper that returns another `Result`, so you get `Result<U>`, not `Result<Result<U>>`.     |
| `andThenAsync(result, mapper)`   | Like `andThen`, with a mapper that may return a promise of a `Result`. Always returns a promise.  |
| `orElse(result, recover)`        | Runs `recover` on an `Err` and returns the `Result` it returns. Returns the `Ok` unchanged.       |
| `orElseAsync(result, recover)`   | Like `orElse`, with a recovery that may return a promise of a `Result`. Always returns a promise. |
| `all(results)`                   | One `Ok` holding every value in order, or the first `Err`.                                        |
| `match(result, { onOk, onErr })` | Calls exactly one of the two callbacks and returns what it returns.                               |
| `unwrap(result)`                 | Returns the `Ok` value, or throws the `AppError`.                                                 |
| `unwrapOr(result, fallback)`     | Returns the `Ok` value, or `fallback`.                                                            |

Put together, a small pipeline reads top to bottom:

```ts
import { andThen, attempt, err, map, match, ok } from "@codenhub/error";

const port = andThen(
  attempt(() => JSON.parse(text) as { port?: unknown }),
  (config) => (typeof config.port === "number" ? ok(config.port) : err({ code: "config.port_missing" })),
);

const label = match(
  map(port, (value) => `Listening on ${value}`),
  {
    onOk: (text) => text,
    onErr: (error) => error.message,
  },
);
```

`orElse` handles the failures you can do something about and passes the rest on. Return `err(error)` for every failure the recovery does not handle; it is already an `AppError`, so it comes back as it is:

```ts
import { all, attemptAsync, err, ok, orElse } from "@codenhub/error";

const projects = orElse(await attemptAsync(() => api.listProjects()), (error) => (error.messageKey === "error.browser.requestFailed" ? ok(cache.projects) : err(error)));
```

`all` turns several results into one, keeping each position's type when you pass a tuple. For calls that run in parallel, await them together first:

```ts
const loaded = all(await Promise.all([attemptAsync(() => api.user(id)), attemptAsync(() => api.projects(id))]));

if (loaded.ok) {
  const [user, projects] = loaded.value;
}
```

The helpers do not catch: if a mapper or a `match` callback throws, the exception propagates as it would from any function. When a step can throw, wrap that step in `attempt` or `attemptAsync`.

Use `unwrap` at the edge where your code goes back to throwing, such as a framework that expects a loader to throw, and keep `Result` everywhere in between.

<!-- Source: docs/frameworks.md -->

# Error boundaries

When a component throws while rendering, React and Svelte replace the failed subtree with a fallback you write. The value they hand that fallback is whatever was thrown, so showing `error.message` leaks raw text to the reader. The two adapters normalize the caught value into an `AppError` first, with the same registry and options as `createAppError`, so the fallback shows a message that is safe to show and your error reporter receives the original value.

Each adapter is its own entrypoint. The framework is an optional peer dependency: install the package next to the framework you already use, and nothing else is pulled in.

| Import path              | What it holds                                     | Needs               |
| ------------------------ | ------------------------------------------------- | ------------------- |
| `@codenhub/error/react`  | The `AppErrorBoundary` component                  | React 18 or 19      |
| `@codenhub/error/svelte` | `createAppErrorBoundary`, for `<svelte:boundary>` | Svelte 5.3 or newer |

## React

`AppErrorBoundary` is an error boundary component. It renders its children until one of them throws, then renders `fallback` with the normalized error and a `reset` function:

```tsx
import { AppErrorBoundary } from "@codenhub/error/react";

export function ProjectPage({ id }: { id: string }) {
  return (
    <AppErrorBoundary
      fallback={({ error, reset }) => (
        <div role="alert">
          <p>{error.message}</p>
          {error.isRetryable && <button onClick={reset}>Try again</button>}
        </div>
      )}
      onError={(error, info) => tracker.captureException(error.originalError, { extra: info })}
      resetKeys={[id]}
    >
      <Project id={id} />
    </AppErrorBoundary>
  );
}
```

| Prop        | What it does                                                                                                    |
| ----------- | --------------------------------------------------------------------------------------------------------------- |
| `fallback`  | Required. Renders in place of the children with `{ error, reset }`.                                             |
| `onError`   | Called once per caught error, after the fallback renders, with the same `AppError` and React's error info.      |
| `options`   | Normalization options, as for `createAppError`: `registry`, `fallbackMessage`, and the rest.                    |
| `resetKeys` | Values that reset the boundary when one of them changes, compared with `Object.is`, such as the route or an id. |
| `onReset`   | Called when the boundary resets, through `reset` or a change of `resetKeys`.                                    |

`reset` renders the children again. If whatever made them throw has not changed, they throw again and the fallback comes back; reset after the state that caused the failure changes, or let `resetKeys` do it when the reader navigates.

## Svelte

Svelte 5 has `<svelte:boundary>` built in. `createAppErrorBoundary` returns the two pieces it needs: an `onerror` handler and a `toAppError` function for the `failed` snippet.

```svelte
<script>
  import { createAppErrorBoundary } from "@codenhub/error/svelte";

  const boundary = createAppErrorBoundary({
    options: { fallbackMessage: "This page failed to load." },
    onError: (error) => tracker.captureException(error.originalError),
  });
</script>

<svelte:boundary onerror={boundary.onerror}>
  <Project />

  {#snippet failed(error, reset)}
    <div role="alert">
      <p>{boundary.toAppError(error).message}</p>
      <button onclick={reset}>Try again</button>
    </div>
  {/snippet}
</svelte:boundary>
```

`onError` receives the `AppError` and the boundary's `reset`. `toAppError` returns the same `AppError` instance for the error `onerror` saw, so the snippet and the reporter agree. The options are validated when you call `createAppErrorBoundary`, not when the first error arrives. The helper does not import Svelte; it only follows the shape the boundary calls.

Create one set of handlers per boundary: each remembers the last error it normalized.

## What a boundary does not catch

Both frameworks catch errors thrown while rendering and, in Svelte, while running effects. Neither catches an error thrown in an event handler, a `setTimeout`, or a promise nobody awaited during rendering, and React's boundaries do not run during server rendering. Handle those where they happen, with `attempt` or `attemptAsync`:

```ts
import { attemptAsync } from "@codenhub/error";

async function save() {
  const result = await attemptAsync(() => api.saveProject(draft));

  if (!result.ok) {
    showToast(result.error.message);
  }
}
```

For a whole-page failure in a framework with its own error page, such as a SvelteKit `+error.svelte` or a Next.js `error.tsx`, call `createAppError` on the error that page receives.

<!-- Source: docs/recipes.md -->

# Recipes

Each recipe here solves one problem from start to finish. Copy the parts you need; none of them depends on another.

## Classify HTTP responses

`fetch` only rejects when the request could not be made at all. A response with status `404` or `500` resolves normally, so `createAppError(response)` has nothing to recognize and returns an `"unknown"` error. Status codes need a mapping of their own.

Pass the status as the `code` and classify it against the HTTP preset. Keep that preset out of the global registry: status codes are short numbers that could collide with other services' codes, and they only mean something for responses.

```ts
import { attemptAsync, err, type Result } from "@codenhub/error";
import { httpErrorRegistry } from "@codenhub/error/registries/http";

export async function getJson<T>(url: string): Promise<Result<T>> {
  const sent = await attemptAsync(() => fetch(url));

  if (!sent.ok) {
    return sent; // The request never completed; the global registry classified it.
  }

  const response = sent.value;

  if (!response.ok) {
    const body: unknown = await response.json().catch(() => null);
    return err({ code: response.status, cause: body }, { registry: httpErrorRegistry });
  }

  return attemptAsync(() => response.json() as Promise<T>);
}
```

Passing the body as `cause` keeps it on `originalError` for your logs. A status the preset does not map, such as `418`, resolves to the fallback message. [Presets](docs/presets.md#http) lists what it maps. To word a status for one endpoint, such as a `401` from a sign-in form, build a registry from the preset and add your own: `createErrorRegistry([httpErrorRegistry])`, then `.codes.add("401", …)`.

When the server is your own and sends `error.toJSON()`, rebuild its error instead: `appErrorFromJSON(body.error)`. See [The AppError](docs/app-error.md#sending-an-error-across-a-boundary).

## Handle Supabase calls

The Supabase client returns failures instead of throwing them, as `{ data, error }`. Pass the `error` to `err` and the Supabase preset does the rest:

```ts
import { err, ok, type Result } from "@codenhub/error";

export async function signIn(email: string, password: string): Promise<Result<Session>> {
  const { data, error } = await supabase.auth.signInWithPassword({ email, password });

  return error ? err(error) : ok(data.session);
}
```

This assumes the Supabase preset is merged into the global registry, as in [Getting started](docs/getting-started.md). The result's `code` tells you which failure it was when you need to act on it:

```ts
const result = await signIn(email, password);

if (!result.ok && result.error.code === "email_not_confirmed") {
  redirectTo("/confirm-email");
}
```

Database errors work the same way: `supabase.from("projects").insert(row)` returns an `error` whose `code` is a PostgreSQL code such as `"23505"`.

## Raise your own errors

An error you raise with a code is only as good as its registration: `err({ code: "project.name_mising" })`, with a typo or a mapping nobody added, is an `"unknown"` error whose `code` is `null`, and a check for `error.code === "project.name_missing"` never fires. Nothing warns you.

Keep your codes and their feedback in one object, register it from there, and raise errors through a function that only accepts its keys. A typo is then a type error, and every code you can raise is registered:

```ts
import { err, getErrorRegistry, type ErrorFeedback } from "@codenhub/error";

export const projectErrors = {
  "project.name_missing": { message: "Give the project a name, then save again.", source: "my-app.projects" },
  "project.not_found": { message: "We couldn't find that project.", source: "my-app.projects" },
} satisfies Record<string, ErrorFeedback>;

export type ProjectErrorCode = keyof typeof projectErrors;

getErrorRegistry().codes.addList(Object.entries(projectErrors));

export const projectError = (code: ProjectErrorCode, details?: Record<string, unknown>) => err({ code }, { details });
```

Then `projectError("project.name_missing", { field: "name" })` reads like what it is, and `error.code` can be compared with a `ProjectErrorCode`.

When codes are registered in more than one place, check them in a test instead; see [Set up errors in tests](#set-up-errors-in-tests).

## Retry safely

`isRetryable` says a failed call can be repeated without the risk that it runs twice. Retry on it only around the call that failed, not around a larger operation that contains it:

```ts
import { attemptAsync, type Result } from "@codenhub/error";

export async function withRetry<T>(call: () => Promise<T>, attempts = 3): Promise<Result<T>> {
  for (let attempt = 1; ; attempt += 1) {
    const result = await attemptAsync(call);

    if (result.ok || !result.error.isRetryable || attempt === attempts) {
      return result;
    }

    await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
  }
}

const users = await withRetry(() => db.query("select * from users"));
```

Wrap the single database query or the single `fetch`, not a `publish()` function that uploads a file and then saves a record. If the save fails with a retryable `ECONNREFUSED`, repeating `publish()` uploads the file a second time. [The AppError](docs/app-error.md#isretryable) explains why the flag is this strict.

## Translate messages

Every preset mapping, and any mapping you register with one, carries a `messageKey`. Look the key up in your catalog and fall back to the English message when the catalog has no entry:

```ts
import type { AppError } from "@codenhub/error";

export function errorText(error: AppError, translate: (key: string) => string | undefined): string {
  return (error.messageKey === null ? undefined : translate(error.messageKey)) ?? error.message;
}
```

The keys are stable across releases, and each always carries the same message. [Presets](docs/presets.md) lists every built-in key. To build a catalog in code, read them from the raw mappings:

```ts
import { browserErrorNames, browserErrorPatterns } from "@codenhub/error/registries/browser";

const browserKeys = new Set([...Object.values(browserErrorNames).map((feedback) => feedback.messageKey), ...browserErrorPatterns.map(([, feedback]) => feedback.messageKey)]);
```

ErrorKit does not ship translations.

## Report to an error tracker

Report what nobody described yet, plus the failures you have described that are still bugs:

```ts
import type { AppError } from "@codenhub/error";

// Failures only a developer can fix. The presets give them all one shared message.
const BUG_CODES = new Set(["42P01", "42703", "unexpected_failure", "InvalidStateError"]);

export function shouldReport(error: AppError): boolean {
  return error.type === "unknown" || (error.code !== null && BUG_CODES.has(error.code));
}

if (shouldReport(error)) {
  errorTracker.captureException(error.originalError, { tags: { source: error.source ?? "none" } });
}
```

Report `originalError`, not the `AppError`: the original carries the real message and stack. Most trackers also follow an error's `cause`, and an `AppError`'s `cause` is the same original value, so reporting the `AppError` itself works too.

Do not report on `type === "heuristic"` alone. A heuristic match is usually a network failure on the reader's side, which is not something you can fix.

## Ignore cancelled requests

Cancelling a request with an `AbortController` makes it fail with an `AbortError`, which the browser preset turns into "The request was cancelled before it finished." When your own code cancelled it, as an effect cleanup or a newer search replacing an older one does, nobody should see that:

```ts
const result = await attemptAsync(() => fetch(url, { signal }));

if (!result.ok && signal.aborted) {
  return; // We cancelled it ourselves.
}
```

Checking `signal.aborted` works with or without the browser preset, and it does not hide an abort that came from somewhere else.

## Set up errors in tests

The global registry is shared by every test in the same file. Replace it in a setup hook so each test starts from the same mappings:

```ts
import { createErrorRegistry, setErrorRegistry } from "@codenhub/error";
import { supabaseErrorRegistry } from "@codenhub/error/registries/supabase";
import { beforeEach } from "vitest";

beforeEach(() => {
  setErrorRegistry(createErrorRegistry([supabaseErrorRegistry]));
});
```

Assert on `code`, `messageKey`, or `type` rather than on `message`. Messages are written for people and get reworded; codes and keys are the stable part.

To catch a code your application raises but never registered, list them and check each one against the registry your application builds at startup:

```ts
import { getErrorRegistry } from "@codenhub/error";
import { expect, it } from "vitest";

import { RAISED_CODES } from "../src/errors";
import { setUpErrors } from "../src/setup-errors";

it.each(RAISED_CODES)("registers %s", (code) => {
  setUpErrors();
  expect(getErrorRegistry().codes.get(code)).toBeDefined();
});
```

<!-- Source: docs/caveats.md -->

# Caveats and limitations

Everything on this page is deliberate, but each item has surprised someone. Each one says what happens, why, and what to do instead.

## Unmatched text never becomes the message

**What happens:** `createAppError(new Error("Email is required"))` has the message `"An unexpected error occurred."`, not `"Email is required"`. So does `err("Email is required")`.

**Why:** raw error messages are written for developers and often contain table names, file paths, query fragments, or other people's data. ErrorKit only shows text that someone registered on purpose.

**Instead:** raise your own failures with a registered code, `err({ code: "signup.email_missing" })`, or pass a `fallbackMessage` that fits the place you are in.

## A code nobody registered is lost

**What happens:** `createAppError({ code: "project.name_missing" })` is an `"unknown"` error with `code: null` until that code is registered. A typo in the code, or a mapping added in a file that never ran, fails silently.

**Why:** `code` names the mapping that classified the error, so a branch on it only ever sees codes someone described. Keeping the raw code would also send whatever code a database or service produced to the client in `toJSON()`.

**Instead:** raise your own errors through one object of codes, so a typo is a type error, or check the codes in a test; see [Recipes](docs/recipes.md#raise-your-own-errors).

## The global registry is shared state

**What happens:** `getErrorRegistry()` returns one registry per copy of the package, shared by everything that imports that copy. On a server, every request sees the same mappings.

**Why:** it lets you configure errors once, at startup, and normalize anywhere without passing a registry around.

**Instead:** configure the global registry once, before handling any request, and never change it per request; two concurrent requests would see each other's changes. For mappings that only apply in one place, pass `{ registry }` to that call. In tests, reset it with `setErrorRegistry` (see [Recipes](docs/recipes.md#set-up-errors-in-tests)).

## Two copies of the package do not know each other

**What happens:** if your application ends up with two copies of `@codenhub/error`, such as two versions installed side by side, each copy has its own global registry, and `isAppError` from one copy returns `false` for an error made by the other.

**Why:** `isAppError` recognizes only errors this copy created, so that objects shaped like an `AppError`, including ones built from untrusted JSON, are never mistaken for one.

**Instead:** keep one version installed. `pnpm why @codenhub/error` shows where a second copy comes from. An `AppError` from another copy is still normalized, as an ordinary object.

## The last mapping added wins

**What happens:** registering an identifier that already exists replaces it, and `merge` overwrites matching entries. Merging the Supabase preset after your own `23505` mapping replaces yours with the preset's.

**Instead:** merge presets first and add your own mappings last. When two sources use the same codes for different things, give each its own registry; see [Registries](docs/registries.md#the-global-registry-or-your-own).

## Names match any error with that name

**What happens:** with the browser or Node.js preset merged, any error whose `name` is `TimeoutError` is classified as that preset's, including errors from a library that happens to use the same class name. The browser preset does the same for `NotFoundError` and its other names.

**Instead:** classify that library's errors against a registry without the preset, or register a more specific code for them.

## Cancelled requests are errors too

**What happens:** cancelling a `fetch` with an `AbortController` rejects it with an `AbortError`, which the browser preset turns into "The request was cancelled before it finished." That includes cancellations your own code made on purpose.

**Instead:** check whether you cancelled it, and skip showing anything; see [Recipes](docs/recipes.md#ignore-cancelled-requests).

## HTTP status codes are not errors to `fetch`

**What happens:** a `fetch` that receives a `404` or `500` resolves normally. Passing the `Response` to `createAppError` produces an `"unknown"` error, because a response has no code, name, or message to match.

**Instead:** check `response.ok` and classify the status yourself; see [Recipes](docs/recipes.md#classify-http-responses).

## Matching is case-sensitive, except in patterns

**What happens:** a registered message `"Upload failed"` does not match `"upload failed"`. Codes, names, messages, and prefixes are all compared case-sensitively.

**Instead:** register the exact text the failing code produces, or use a pattern with the `i` flag. Trailing punctuation and spaces never matter for messages and prefixes.

## Wrapped errors are searched to a limited depth

**What happens:** `createAppError` looks three levels deep into wrapper fields, and eight levels down a `cause` chain. It reads only the first ten entries of an `errors` list. A failure buried deeper resolves as if it were not there.

**Why:** every level can multiply the number of values to inspect, and the input can come from anywhere. The limits keep the work small on any input.

**Instead:** normalize closer to where the failure happens, or wrap failures with `cause`, which is followed furthest.

## Retryable is narrower than "might work next time"

**What happens:** a timeout, a reset connection, or a failed `fetch` in the browser is never marked retryable, even though trying again often works.

**Why:** each of those can happen after the server already acted on the request, and repeating a request that already ran can charge a card twice. `isRetryable` promises safety, not likelihood.

**Instead:** retry those yourself only where repeating the request is harmless, such as a `GET`. See [The AppError](docs/app-error.md#isretryable).

## Presets do not cover everything

- The browser preset recognizes a failed `fetch` and a failed `import()` by the messages Chromium, Firefox, and Safari produce today. A browser that rewords them falls back to `"unknown"` until the preset catches up.
- The Node.js preset covers network failures and the errors an `AbortSignal` raises, and was observed on Node.js 24. Other server runtimes may report the same failures differently.
- The Supabase preset covers the Auth codes a person can act on, six PostgreSQL codes, and the client's own error names. It maps the few developer faults a running application commonly hits, such as a missing table or column, to one shared message, so they are `"known"`: report them by `code`, not by type (see [Recipes](docs/recipes.md#report-to-an-error-tracker)). Other codes only a developer can fix, such as `bad_jwt`, are left unmapped on purpose and reach your error tracker as `"unknown"`.

Register your own mapping for anything a preset misses. [Presets](docs/presets.md) lists exactly what each one covers.

## No translations ship with the package

Every preset mapping has a stable `messageKey`, but ErrorKit includes only the English messages. Translate the keys in your own catalog; see [Recipes](docs/recipes.md#translate-messages).

## Structured clone drops the fields

**What happens:** sending an `AppError` through `postMessage`, `structuredClone`, or IndexedDB gives the other side a plain `Error` without `type`, `code`, or the other fields, and on some engines with the raw `cause` attached.

**Instead:** send `error.toJSON()` and rebuild it with `appErrorFromJSON` on the other side; see [The AppError](docs/app-error.md#sending-an-error-across-a-boundary).

## Patterns you write can be slow

A pattern runs against every message `createAppError` sees, and a message can be crafted by someone trying to slow your server down. A pattern with nested repetition, such as `/(a+)+$/`, can take seconds on the wrong input. Keep custom patterns anchored and simple; see [Registries](docs/registries.md#writing-patterns). The built-in patterns are all anchored, with no nested repetition.

<!-- Source: docs/changelog/0.3.0.md -->

# 0.3.0

## Added

- `attempt` and `attemptAsync` convert throwing code into a `Result` without a hand-rolled `try`/`catch`, which was the only remaining way to reach `err()` from a throwing boundary. Both validate their options before running the callback.
- `orElse` and `orElseAsync` recover from a failed `Result`, and `all` combines a list of results into one, throwing `TypeError` for an entry that is not a `Result`. Falling back on a specific failure, or collecting the results of parallel calls, took a hand-written `match` or `if` at every call site.
- An HTTP preset at `@codenhub/error/registries/http` maps twelve client and server error statuses, none retryable, for classifying a failed response by its status. Every application that used `fetch` copied the status registry from the recipe.
- Error-boundary adapters: `AppErrorBoundary` at `@codenhub/error/react` and `createAppErrorBoundary` at `@codenhub/error/svelte` hand a React or Svelte fallback a normalized `AppError` and report each caught error once. React 18 or newer and Svelte 5.3 or newer are optional peer dependencies; the core entrypoints still have no dependencies. A fallback written by hand showed the raw thrown message unless every application remembered to normalize it.
- `AppError.details` carries structured data the code attached with the new `details` option, such as the form field that failed, and `toJSON()` and `appErrorFromJSON` carry it across a boundary. It accepts JSON values only, dropping a key whose value is `undefined` as `JSON.stringify` does, and is never read from the raw error. Data like that had nowhere to go but `originalError`, which never leaves the process.
- A generated API reference ships with the package, covering every `exports` subpath.
- A Node.js preset at `@codenhub/error/registries/node` classifies network failures from sockets, DNS, and the built-in `fetch`, such as `ECONNREFUSED`, `ENOTFOUND`, and `UND_ERR_CONNECT_TIMEOUT`. A failed `fetch` on Node.js used to resolve to an unknown error under every built-in preset.
- `AppError.code` holds the registered code that classified the error, or the registered name when no code matched, and `toJSON()` includes it. Consumers can branch on a specific failure without comparing message text or a translation key.
- Entries of an `errors` list, as on `AggregateError`, are classified. The first ten are inspected.
- The Supabase preset maps the Auth codes a person using the application can run into and act on, 41 where it had 10: password rules, existing email and phone, ended sessions, expired links and invitations, two-step verification, suspended accounts, interrupted sign-in flows, and the request rate limit.
- The browser preset classifies a code-split chunk that fails to load, by webpack's `ChunkLoadError` name and by the message Chromium, Firefox, and Safari each produce for a failed `import()`, and tells the reader to reload the page. It also maps the `NotReadableError` and `EncodingError` names.
- The Node.js preset maps `EAI_AGAIN`, `EHOSTUNREACH`, `ENETUNREACH`, and `EPIPE`, and the `TimeoutError` and `AbortError` names an `AbortSignal` raises, exported as `nodeErrorNames`. A `fetch` given `AbortSignal.timeout()` on a server resolved to an unknown error, and merging the browser preset for it told the reader to check their internet connection.
- The Supabase preset classifies two errors the Supabase Auth client raises itself: `AuthSessionMissingError`, which carries no code and is what most calls return without a session, as an ended session, and `pkce_code_verifier_not_found`, from a sign-in link opened in another browser, as an interrupted sign-in. Both resolved to an unknown error.
- `appErrorFromJSON` rebuilds an `AppError` from the shape `toJSON()` produces, so a failure a server sent as JSON can be held in a `Result` on the client. Passing the parsed object to `createAppError` lost every name, message, prefix, and pattern match. The sender chooses the message, so it is for sources the application trusts.
- The `./package.json` subpath is exported, so tools that read the package's metadata can resolve it.
- A `default` export condition, so CommonJS can `require()` the package on runtimes that support requiring ES modules.

## Changed

- **Breaking.** The package requires Node.js 24 or newer, where it declared 22. CI had only ever run it on 24, so the 22 claim was untested; it now names the line CI runs. Browser, worker and edge runtimes are unaffected. ([#203](https://github.com/codenhub/codenhub/issues/203))
- **Breaking.** Strings are classified against the registry at every wrapper depth. Previously only a top-level string bypassed matching, so `err("Failed to fetch")` resolved to the generic fallback while `err({ cause: "Failed to fetch" })` resolved through the registry. A message, prefix, or pattern mapping that matches a top-level string passed to `createAppError` or `err()` now produces a known or heuristic result instead of `unknown`. Registry messages are curated and an unmatched string still resolves to the fallback, so nothing raw reaches a consumer either way.
- **Breaking.** `ok` and `err` results are frozen, so code that mutated one now fails.
- **Breaking.** The browser fetch and network pattern uses its own `error.browser.requestFailed` key rather than `error.browser.network`, so no translation key has to cover two different fallback messages. A translation map covering the old key needs the new one.
- **Breaking.** The browser failed-fetch pattern matches only the whole message an engine produces. Text that merely contains `load failed` or `networkerror`, such as `Config load failed`, is no longer classified as a network failure.
- **Breaking.** `attempt` rejects a callback that returns a promise at the type level, and throws `TypeError` when a callback the type checker cannot see through, such as one typed `any`, returns a promise or other thenable. Its rejection used to escape inside an `Ok`; use `attemptAsync`.
- **Breaking.** The browser `TimeoutError` mapping is not retryable. `isRetryable` means the operation can be repeated without the risk that it runs twice, and a timeout can fire after the server received the request; the Node.js preset already treated response timeouts that way.
- A `cause` chain is followed to a depth of 8 at the default `maxDepth`, where every wrapper field stopped at 3. A failure wrapped once per layer, such as `service → repository → SDK → fetch failed → ECONNREFUSED`, resolved to the generic fetch pattern or to an unknown error; the other fields still stop at 3, where they would multiply the candidates.
- A `messageKey` or `source` is any non-empty string. The repository's naming conventions were enforced at runtime, so an application could not register the keys its translation catalog already had.
- **Breaking.** An existing `AppError` is classified again only when a `registry` is supplied, and then only ever upgraded: a pattern match replaces an unknown error, a known match replaces anything it differs from, and everything else returns the same object. A lone `fallbackMessage`, or the default `maxDepth` written out, used to turn a known error into an unknown one, and a `fallbackMessage` replaced the message of an unknown error, so the message alternated as an error passed through layers that each supplied one. A `fallbackMessage` now words an unknown error only while it still carries the default message, so the first layer to supply one keeps its wording, and a layer that wraps a call whose own layer named no fallback, such as an `unwrap` inside `attemptAsync`, still gets its own. Either option also classified the error against the global registry, replacing a classification made by a per-service registry.
- **Breaking.** The Supabase `FunctionsRelayError` mapping is not retryable, and its message tells the reader to check whether the call went through. The relay reports the error on a response, after the function may have run.
- **Breaking.** An `AppError` is no longer frozen. Its own properties still cannot be changed or removed, but new ones can be added: Koa's error handler assigns `status` and hapi's `Boom.boomify` assigns `isBoom` to every error they catch, and on a frozen `AppError` that assignment threw. In Koa the rejection went unhandled, which ends a Node.js process. `Object.isFrozen(error)` is now `false`. The `stack` stays unlocked, because a library that rethrows a caught error calls `Error.captureStackTrace` on it, and on a locked stack that call throws.
- **Breaking.** An `AppError` nested in another value keeps its classification unless a `registry` is supplied. Wrapping one, as in `new Error("checkout failed", { cause: appError })`, used to have the raw value inside it classified against the global registry, which replaced an unknown or heuristic classification made by a per-service registry and could mark the result retryable. A supplied `registry` now also rewords a nested known `AppError`, as it already did one passed directly; the nested one used to end the search.
- `mapAsync` and `andThenAsync` accept a mapper that returns a plain value or `Result` as well as a promise, so a mapper that is asynchronous only on some paths type-checks.
- **Breaking.** The Supabase rate-limit mappings are not retryable. `isRetryable` carries no delay, so code that retried on it did so at once and counted against the limit again; the message tells the reader how long to wait.
- **Breaking.** Every preset message is rewritten to say what happened and what to do next, where it used to restate the code: `invalid_credentials` reads "We couldn't sign you in. The email or password is incorrect. Check them and try again." Failures only a developer can fix, such as a missing table or column, share one message that tells the reader the problem is not their doing and names no internals. The `messageKey` values are unchanged.
- **Breaking.** The `"unexpected"` `AppErrorType` is renamed `"heuristic"`. The type says how an error was matched, not how serious it is, and the old name invited `type === "unexpected"` as a test for a defect, which reports an offline user matched by a pattern and misses a missing table matched by its code.
- **Breaking.** `appErrorFromJSON` rebuilds every error with `isRetryable: false`. The sender's flag describes a call the sender made, such as its own database call, so a client that retried the request on it could repeat a request the sender had already acted on. It also normalizes as raw input a value whose fields `toJSON()` cannot produce together, such as an unknown error with a `code` or an empty `messageKey`.
- **Breaking.** The `codes`, `names`, and `messages` buckets' `values()` return a frozen list of `[identifier, feedback]` entries, as the prefix and pattern buckets already returned a list. The iterator they returned had no `length`, and calling `.map` on it worked only on runtimes with iterator helpers.

## Fixed

- The browser failed-fetch pattern and `TimeoutError`, the Node.js `fetch failed` pattern, and the Supabase `FunctionsFetchError` and `FunctionsHttpError` messages tell the reader to check whether the request went through before trying again. They said to try again, and the failed-fetch messages said the server was never reached, although each can follow a request the server already ran, as a CORS block or a dropped response does. The `messageKey` values are unchanged.
- The Node.js preset messages speak about "a service this depends on" and no longer tell the reader to check their internet connection. The preset runs where the failing call was made, most often a server calling another service, so the reader's own connection was not the one that failed. The `messageKey` values are unchanged.
- Normalizing a message is linear in its length. A long message with a run of `.`, `!` or `?` inside it took quadratic time, with or without registered mappings: 80 KB took over seven seconds.
- `attempt` and `attemptAsync` reject an out-of-range `maxDepth` before running the callback. It used to surface only when the callback failed, replacing the real failure.
- A `DOMException` is classified by its name; its legacy numeric `code` could match an unrelated registered code.
- A message or prefix mapping whose trailing punctuation is broken up by whitespace, such as `Done . .`, matches. Its key was normalized once when stored and again on lookup, to a different result, so it never matched until the registry was merged or frozen.
- `attempt` and `attemptAsync` throw `TypeError` for a callback that is not a function instead of returning an `Err`.
- An `AppError` no longer exposes the class that builds it: `constructor` is `Error` and the shared prototype is frozen, so an `AppError` cannot be built around `createAppError` and `appErrorFromJSON`, and `toJSON` cannot be replaced for every instance.
- Feedback without a message returned by a hand-written registry throws `TypeError` instead of producing an `AppError` with no message.

## Removed

- **Breaking.** The browser `connection refused|dns_probe_finished` pattern and its `error.browser.connectionRefused` key. No browser is known to produce that text in an error message, and it classified unrelated server messages relayed to the client as a retryable browser network failure.
- **Breaking.** The Supabase `invalid_grant` mapping and its `error.supabase.auth.invalidGrant` key. It is not among the codes Supabase Auth publishes, so it could not match.
- The internal `createUntrustedAppError` variant, made redundant by classifying strings at every depth.
