# ts-extended-errors > A zero-dependency error model for TypeScript applications that use native > exceptions but need typed context, cause-chain inspection, and reliable JSON > round trips. `ExtendedError` is a base class > that keeps `name`, `code`, `context` and `stack` correct through subclassing; > `defineError` declares such a class in one line and builds taxonomies through > its `base` option; five helpers search the `cause` chain; `serializeError` and > `deserializeError` take an error through JSON and rebuild it as the classes it > was. Every function accepts `unknown`, because a `catch` binding is `unknown` > and JavaScript permits throwing anything. No runtime dependencies and no > Node.js APIs, so it runs in browsers and workers as well as on Node >= 20.19. > Ships ESM and CommonJS with type declarations. Published to npm. MIT. --- # ts-extended-errors Source: https://rxova.org/packages/ts-extended-errors/ `ts-extended-errors` is one small package for the part of an application that only runs when something has already gone wrong. It gives you error classes that behave the way you expected JavaScript's to behave, and a way to move them across a boundary — a queue, a worker, an HTTP response, a log line — without losing what they were. Node.js 20.19 or newer, no runtime dependencies, no Node APIs (so it runs in browsers and workers too), ESM and CommonJS with type declarations. MIT. ```bash npm install ts-extended-errors ``` ## In one example ```ts import { defineError, findCauseOf, serializeError, toError } from 'ts-extended-errors' const HttpError = defineError('HttpError', { code: 'HTTP' }) const NotFoundError = defineError('NotFoundError', { base: HttpError, code: 'HTTP_NOT_FOUND' }) function loadProfile(userId: number) { try { throw new NotFoundError('no such user', { context: { userId } }) } catch (cause) { throw new HttpError('loading the profile failed', { cause }) } } try { loadProfile(42) } catch (thrown) { const error = toError(thrown) // `thrown` is `unknown`; `error` is an `Error` // Searches the whole cause chain, not just the outermost error. const notFound = findCauseOf(error, NotFoundError) notFound?.code // 'HTTP_NOT_FOUND' notFound?.context // { userId: 42 } console.log(JSON.stringify(serializeError(error, { includeStack: false }))) // {"name":"HttpError","message":"loading the profile failed","code":"HTTP","cause": // {"name":"NotFoundError","message":"no such user","code":"HTTP_NOT_FOUND", // "context":{"userId":42}}} } ``` ## What it gives you - **Subclasses that work.** `name` is the class name, `instanceof` holds after downlevel compilation, and the stack starts at the throw site rather than inside the constructor. See [Subclassing `Error`](https://rxova.org/packages/ts-extended-errors/under-the-hood/subclassing.md). - **A code and a context.** A stable string per class to branch on, and a typed data object per error, so failure details stop being interpolated into prose and parsed back out. - **One-line classes.** `defineError('NotFoundError', { base: HttpError, code: 'HTTP_NOT_FOUND' })` builds a real class, and `base` builds families of them. - **Cause chains you can search.** A low-level failure is usually wrapped two or three times before it reaches the handler that knows what to do about it. - **A JSON round trip.** `serializeError` produces a plain object; `deserializeError` rebuilds it as the classes it was, so `instanceof` still works on the other side. - **Tolerance for anything thrown.** Every function takes `unknown`, including strings, plain objects and `null`. ## Where to go next - New to it: [Why this exists](https://rxova.org/packages/ts-extended-errors/learn/why.md), then [Getting started](https://rxova.org/packages/ts-extended-errors/learn/getting-started.md). - Building a set of error classes: [Defining errors](https://rxova.org/packages/ts-extended-errors/guides/defining-errors.md). - Reporting failures: [Cause chains](https://rxova.org/packages/ts-extended-errors/guides/cause-chains.md) and [Serialization](https://rxova.org/packages/ts-extended-errors/guides/serialization.md). - Looking something up: [API reference](https://rxova.org/packages/ts-extended-errors/reference/api.md) and [Types](https://rxova.org/packages/ts-extended-errors/reference/types.md). ## For coding agents Every page here is also served as raw markdown — add `.md` to any URL. There is an [`llms.txt`](https://rxova.org/packages/ts-extended-errors/llms.txt) index and an [`llms-full.txt`](https://rxova.org/packages/ts-extended-errors/llms-full.txt) with every page inlined. The package also ships its own `llms.txt` inside the tarball, readable from `node_modules/ts-extended-errors/llms.txt` with no network access. --- # Getting started Source: https://rxova.org/packages/ts-extended-errors/learn/getting-started/ ## Install ```bash npm install ts-extended-errors ``` No peer dependencies and nothing else to configure. Node.js 20.19 or newer; the package uses no Node APIs, so bundlers targeting browsers and workers are equally fine. Both ESM and CommonJS entry points ship, with type declarations for each. TypeScript is not required, but the types are most of the point: `context` is typed per class, and `findCauseOf` narrows to the class you pass it. ## Declare a class `defineError` returns a real class. Call it once, at module scope, and export the result — two calls return two different classes, and `instanceof` between them is false. ```ts // errors.ts import { defineError } from 'ts-extended-errors' export const AppError = defineError('AppError', { code: 'APP' }) export const NotFoundError = defineError<{ id: string }>('NotFoundError', { base: AppError, code: 'NOT_FOUND', }) ``` `base` is what makes it a family: `NotFoundError` is an `AppError`, so a handler can catch the whole family without knowing the leaves. The type parameter types `context` for this class. ## Throw it The second argument is an options object — `context` and `cause` are fields of it, not positional arguments. ```ts import { NotFoundError } from './errors.js' export function loadUser(id: string) { const user = users.get(id) if (!user) throw new NotFoundError(`no user ${id}`, { context: { id } }) return user } ``` ## Wrap what you catch When a failure crosses a layer, wrap it rather than replacing it. The original goes in `cause`, and nothing is lost. ```ts import { AppError } from './errors.js' export function loadProfile(id: string) { try { return render(loadUser(id)) } catch (cause) { throw new AppError('loading the profile failed', { cause }) } } ``` ## Catch it A `catch` binding is `unknown`, because JavaScript permits throwing anything. `findCauseOf` takes `unknown`, searches the whole chain, and narrows to the class you asked for: ```ts import { findCauseOf, toError } from 'ts-extended-errors' import { NotFoundError } from './errors.js' try { loadProfile('u_17') } catch (thrown) { const notFound = findCauseOf(thrown, NotFoundError) if (notFound) return respond(404, { id: notFound.context?.id }) // Anything else: narrow to a real Error and let the logger have it. logger.error(toError(thrown)) return respond(500) } ``` Using `thrown instanceof NotFoundError` here would return false: the error that reached this handler is the `AppError` wrapping it. That is the one mistake worth internalising early — see [Cause chains](https://rxova.org/packages/ts-extended-errors/guides/cause-chains.md). ## Log it, or send it `JSON.stringify(error)` already works, because every class here defines `toJSON`. For control over what is included, call `serializeError` directly: ```ts import { serializeError } from 'ts-extended-errors' logger.error(serializeError(error)) // with stacks, for your own logs response.json(serializeError(error, { includeStack: false })) // without, for a client ``` On the receiving end, `deserializeError` turns that object back into the classes it was. See [Serialization](https://rxova.org/packages/ts-extended-errors/guides/serialization.md). ## Next - [Defining errors](https://rxova.org/packages/ts-extended-errors/guides/defining-errors.md) — codes, taxonomies, generated messages, and when to use a `class` declaration instead. - [Cause chains](https://rxova.org/packages/ts-extended-errors/guides/cause-chains.md) — the five search helpers. - [Working with unknown values](https://rxova.org/packages/ts-extended-errors/guides/unknown-values.md) — `toError`, `isErrorLike`, `describeValue`. --- # Why this exists Source: https://rxova.org/packages/ts-extended-errors/learn/why/ Nothing here is difficult. Each problem below has a well-known fix, each fix is three lines, and each is easy to forget on the fifth error class. The package exists so that they are handled once. ## Subclassing `Error` does not give you a subclass Extend `Error` and four things are quietly wrong: ```ts class ConfigError extends Error {} const error = new ConfigError('missing "port"') error.name // 'Error' — not 'ConfigError' ``` `name` stays `'Error'`, so every log line and every `toString()` says `Error` for a class you carefully named. `instanceof ConfigError` is false once the class is compiled down to ES5 or run through a bundler that does, because `super()` returns a fresh `Error` object and the prototype chain is lost. The stack's top frame is the constructor rather than the line that threw. And `JSON.stringify(error)` is `{}`, because `message` and `stack` are non-enumerable on `Error`. [`ExtendedError`](https://rxova.org/packages/ts-extended-errors/under-the-hood/subclassing.md) fixes all four in its constructor, so a subclass of it inherits the fixes rather than restating them. ## Branching on the message Without a stable discriminator, callers end up matching on prose: ```ts if (error.message.includes('not found')) { /* … */ } ``` That breaks the first time somebody rewords the message — which is a thing messages are for. Every class here carries a `code`: a short machine-readable string, set once on the class rather than at every throw site. ## Details interpolated into the message The information a handler needs is usually formatted into the sentence and then unavailable: ```ts throw new Error(`user ${userId} not found in tenant ${tenantId}`) ``` `context` is a typed object on the error, so the data stays data. The message is still a sentence for a human, and `error.context.userId` is still a number. ## The error stops being an error at a boundary Send an error through `JSON.stringify`, a `postMessage`, or a job queue and what arrives is a plain object. `instanceof` is false for it, the `cause` chain is gone or flattened, and the handler on the far side is back to reading strings. [`serializeError` and `deserializeError`](https://rxova.org/packages/ts-extended-errors/guides/serialization.md) are the two halves of that round trip: a JSON-safe object going out, the original classes coming back — including the causes, so `findCauseOf(error, NotFoundError)` works on the receiving end. ## Choosing an error model “Typed” here describes an error after it has been caught and narrowed: its class, `code` and `context`. TypeScript does not declare thrown errors in a function's signature, so this package does not provide checked exceptions. | Need | Model | | ---------------------------------------------------------------------------- | ---------------------------------------------------- | | A message and stack that nobody branches on | Native `Error` | | Structured exceptions, searchable causes and a JSON boundary | `ts-extended-errors` | | Expected failures that every caller sees in the function's return type | A discriminated union or `Result` | | Typed failures as part of a wider runtime for effects, resources and retries | An effect system | These models compose. An `ExtendedError` subclass can be the error value in a `Result`, while unexpected failures still use native `throw` and `catch`. ## When not to use it - **You throw one kind of error and never inspect it.** A plain `Error` with a good message is fine, and one fewer dependency is worth something. - **Every caller must see each expected failure in the function's type.** Return a discriminated union or a `Result`; TypeScript does not track what a function throws. - **Your application already runs inside an effect system.** Use its failure channel, resource model and retry machinery rather than introducing a parallel control-flow convention. - **You need errors to cross a `vm` or realm boundary and still satisfy `instanceof`.** They cannot; two realms mean two distinct classes. Serialize instead — `serializeError` works on error-shaped values from anywhere, and `deserializeError` rebuilds them locally. - **You want your framework's error type.** If your HTTP framework already defines the taxonomy your handlers switch on, adding a second one costs more than it returns. --- # Cause chains Source: https://rxova.org/packages/ts-extended-errors/guides/cause-chains/ A failure rarely reaches its handler in the form it happened. A socket times out, the HTTP client wraps it, the repository wraps that, and the route handler sees something three layers away from the cause. `cause` is the standard place to keep the original, and these helpers are for reading back down it. ## Wrap, do not replace ```ts try { return await fetchUser(id) } catch (cause) { throw new ProfileError('loading the profile failed', { cause }) } ``` `cause` is forwarded to the native `Error` cause, so anything that already understands it — Node's inspector, most loggers, the browser console — sees it without knowing about this package. The property is defined only when a cause was actually passed. That matters: `'cause' in error` is the check a chain walker uses to decide where to stop, and `super(message, { cause: undefined })` would define it anyway. ## The mistake this section exists for ```ts catch (thrown) { if (thrown instanceof TimeoutError) { /* never runs */ } } ``` `thrown` is the outermost wrapper. The `TimeoutError` is two levels down. `findCauseOf` is the version of that check that looks at the whole chain: ```ts const timeout = findCauseOf(thrown, TimeoutError) if (timeout) return retryLater(timeout.context) ``` ## The helpers All five take `unknown`. `cause` is `unknown` by specification — nothing stops code from throwing `{ cause: 'timeout' }` — and a walker that assumed otherwise would throw while you were trying to report a failure. ### `findCauseOf(error, Class)` The first value in the chain that is an instance of `Class`, typed as that class, or `undefined`. This is the one you reach for in a `catch`. ```ts findCauseOf(error, NotFoundError)?.context // typed as the class's context ``` ### `hasCauseOf(error, Class)` The same question when you only need the boolean. ```ts if (hasCauseOf(error, TimeoutError)) return respond(504) ``` ### `findCause(error, predicate)` For a condition that is not "is an instance of". Pass a type guard and the result is narrowed to it. ```ts const withStatus = findCause( error, (candidate): candidate is { status: number } => typeof candidate === 'object' && candidate !== null && 'status' in candidate, ) ``` ### `rootCause(error)` The deepest value in the chain — the original failure. Returns `error` itself when there is no cause, so it is safe to call unconditionally. ```ts logger.error({ reported: toError(error), root: rootCause(error) }) ``` ### `causeChain(error)` The whole chain as an array, outermost first. An error with no cause yields a one-element array. Use it when you want to render the sequence rather than search it. ```ts causeChain(error) .map((link) => toError(link).message) .join(' ← ') ``` ## Cycles and non-errors `causeChain` stops when it meets a value already on the chain, so `a.cause = b; b.cause = a` yields `[a, b]` rather than looping. It also stops at a primitive, which cannot carry a cause of its own. Every other helper is built on it, so all of them inherit that. The chain may contain values that are not errors. `causeChain` returns them as `unknown`; `findCauseOf` simply will not match them; and [`toError`](https://rxova.org/packages/ts-extended-errors/guides/unknown-values.md) turns any one of them into a real `Error` when you need to log it. ## Across a serialization boundary Causes survive the JSON round trip. `serializeError` walks the chain to a depth limit, and `deserializeError` rebuilds it, so `findCauseOf` works on the receiving end exactly as it did on the sending one — provided the classes you want back are passed in `classes`. See [Serialization](https://rxova.org/packages/ts-extended-errors/guides/serialization.md). --- # Defining errors Source: https://rxova.org/packages/ts-extended-errors/guides/defining-errors/ There are two ways to declare an error class. `defineError` is a function call and covers most cases; `class … extends ExtendedError` is for when the class needs members of its own. ## `defineError` ```ts import { defineError } from 'ts-extended-errors' const TimeoutError = defineError('TimeoutError', { code: 'TIMEOUT' }) const error = new TimeoutError('the upstream did not answer') error.name // 'TimeoutError' error.code // 'TIMEOUT' error instanceof TimeoutError // true error instanceof Error // true ``` The class it returns is a real class — `instanceof` works, it can be subclassed, and the stack is captured at the throw site. What it saves you is the boilerplate, which is exactly the boilerplate people skip. Call it once per class, at module scope, and export the result. Each call returns a _new_ class, so a `defineError` inside a function produces a class per invocation and `instanceof` against any other call is false. ## `code` A short, stable string shared by every instance of the class. It is what callers branch on, because `message` is prose and will be reworded. It is declared once on the class rather than at each throw site, and copied onto every instance so it survives serialization. A class with no `code` of its own inherits its base's — which is usually right for a leaf that callers handle by `instanceof`. ## `context` Structured data about this particular failure. Type it with the first type parameter, or let it be inferred from a `message` function (below). ```ts const RateLimitedError = defineError<{ retryAfterMs: number }>('RateLimitedError', { code: 'RATE_LIMITED', }) throw new RateLimitedError('slow down', { context: { retryAfterMs: 2_000 } }) ``` Keep it serializable. `serializeError` copies `context` through a JSON round trip, so a `Date` arrives as a string and a `Map` as `{}`. ## Taxonomies with `base` `base` sets the class to extend. This is what makes one `catch` keep working as the set of leaves grows: ```ts const HttpError = defineError('HttpError', { code: 'HTTP' }) const NotFoundError = defineError('NotFoundError', { base: HttpError, code: 'HTTP_NOT_FOUND' }) const ForbiddenError = defineError('ForbiddenError', { base: HttpError, code: 'HTTP_FORBIDDEN' }) new NotFoundError('no such user') instanceof HttpError // true ``` A handler written against `HttpError` catches the fifth subclass you add without being touched. ## Messages written from context Repeating the same sentence at every throw site is how messages drift apart. Give the class a `message` function instead, and the throw site passes only the context: ```ts const InvalidDateError = defineError('InvalidDateError', { code: 'INVALID_DATE', message: (context: { value: string }) => `"${context.value}" is not a valid date`, }) const error = new InvalidDateError({ context: { value: '2026-02-30' } }) error.message // '"2026-02-30" is not a valid date' ``` The type of `context` comes from the parameter of `message`, so you do not write it twice. `context` is required at the call site when its type has required fields, and the options argument is optional when it does not. Where it is required, the instance's `context` is typed as present rather than `Context | undefined`, so a caller reads a field off it directly: ```ts const found = findCauseOf(thrown, InvalidDateError) found?.context.value // string — one `?.` for "was it found", and none for the context ``` Such a class still accepts `(message, options)` when a string is passed first. That is what keeps it usable as a `base`, and what lets `deserializeError` rebuild it with the message that was actually sent rather than reformatting one from context that has been through JSON. That constructor cannot require a context without the class ceasing to be an `ErrorClass`, so it defaults one to `{}` — which is what keeps the instance type honest when a payload arrives carrying no `context` at all. If `message` throws, the error is still created — with the class name as its message and `context` intact. A formatter runs on data nobody has checked, usually inside a `catch` that is already handling a failure, and losing the original error to a `TypeError` from the formatter is the worst possible outcome there. ## Other bases `base` also accepts a built-in error class, or any class whose constructor takes `(message, options)`: ```ts const OutOfRangeError = defineError('OutOfRangeError', { base: RangeError, code: 'OUT_OF_RANGE', }) const error = new OutOfRangeError('page 0 does not exist') error instanceof RangeError // true error instanceof ExtendedError // false — a class has one parent error.code // 'OUT_OF_RANGE' error.context // undefined, but typed and settable ``` Instances get everything an `ExtendedError` has — name, `code`, `context`, `cause`, a trimmed stack, `toJSON` — but not `ExtendedError` itself in their prototype chain, so `isExtendedError` returns false for them. That is the honest answer: it is a `RangeError` that carries the same fields. To type `context` as well, name both type parameters; TypeScript will not infer one when you supply the other: ```ts defineError<{ page: number }, RangeError>('PageError', { base: RangeError }) ``` ## `class … extends ExtendedError` Use a class declaration when the error needs members of its own — an extra method, a computed property, a narrower constructor. ```ts import { ExtendedError } from 'ts-extended-errors' class ConfigError extends ExtendedError<{ file: string; key: string }> { static override readonly code = 'CONFIG' get location(): string { return `${this.context?.file ?? ''}:${this.context?.key ?? ''}` } } const error = new ConfigError('missing "port"', { context: { file: 'app.config.json', key: 'port' }, cause: new SyntaxError('Unexpected token }'), }) error.location // 'app.config.json:port' ``` `code` is a `static` field, declared once for the class. The constructor reads it from the class that was actually constructed, so a subclass that declares its own `code` gets that one and one that does not inherits its parent's. ## Which to use `defineError` for a class that is only a name, a code and a context — most of them. A `class` declaration when there is behaviour to attach. They interoperate freely: `base` accepts a class you declared, and you can extend a class `defineError` returned. --- # Serialization Source: https://rxova.org/packages/ts-extended-errors/guides/serialization/ An error that crosses a boundary stops being an error. `JSON.stringify` on a plain `Error` produces `{}`; a `postMessage` gives you a structured clone with no class; a job queue stores text. These two functions are the round trip. ## Out: `serializeError` ```ts import { serializeError } from 'ts-extended-errors' const payload = serializeError(error, { includeStack: false }) ``` The result is a plain object, safe to hand to `JSON.stringify`: ```json { "name": "HttpError", "message": "loading the profile failed", "code": "HTTP", "cause": { "name": "NotFoundError", "message": "no such user", "code": "HTTP_NOT_FOUND", "context": { "userId": 42 } } } ``` It takes `unknown`, not `Error`. Anything error-shaped — including an error from a worker or a second bundled copy of a library, where `instanceof Error` is false — is walked the same way, and a value that is not an error is described rather than dropped, because `throw 'nope'` is rare but real and a serializer that returns `{}` for it is how an incident becomes unreadable. A getter or proxy trap that throws is treated as an inaccessible field: a throwing `cause` ends that chain, while optional metadata is omitted. An object that refuses both JSON and string-tag inspection becomes `''`. Exceptions from a predicate passed to `findCause` or a class constructor passed to `deserializeError` still propagate; those are caller-provided behavior rather than inspection of the unknown value. `context` is _copied_, not referenced, through a JSON round trip taken there and then. The result shares nothing with the error, so a redactor can edit one without touching the other and `JSON.stringify` cannot throw on it later. The cost is that a `Date` in `context` becomes a string and a `Map` becomes `{}`. `JSON.stringify(error)` already calls this, because every class here defines `toJSON`. Call `serializeError` directly when you need the options. ### Options | Option | Default | What it does | | ---------------------- | ------- | --------------------------------------------------------------------------- | | `includeStack` | `true` | Include `stack`. Turn it off for anything a client will see | | `maxDepth` | `8` | How far down the `cause` chain to walk | | `maxAggregatedErrors` | `10` | How many of an `AggregateError`'s `errors` to keep, across the whole output | | `includeOwnProperties` | `false` | Also copy the error's own enumerable fields | ### What to include where ```ts logger.error(serializeError(error)) // your own logs: stacks are the point response.json(serializeError(error, { includeStack: false })) // a client: they are not ``` `includeStack` defaults to `true` because a log line is the common case and a stackless log is useless. The response path is the one that has to say otherwise. JSON-safe does not mean safe to send to a client. `serializeError` does not redact the fixed fields: `message`, `code` and `context` are written as given, and a stack normally repeats the message in its first line. Build a public response from fields you intend to expose instead of treating the serializer as an allowlist. `includeOwnProperties` copies whatever else the error class assigned — a `statusCode`, a `request`, a `user`. What is in those fields is up to whoever threw, so this is for a log you control, not for a response body. Errors held in such fields are serialized like a `cause`, under the same depth limit; anything else goes through the same JSON round trip as `context`; functions, `undefined` and fields whose getter throws are skipped; and the fixed fields are never overwritten. ## Back: `deserializeError` ```ts import { deserializeError } from 'ts-extended-errors' import { HttpError, NotFoundError } from './errors.js' const error = deserializeError(JSON.parse(line), { classes: [HttpError, NotFoundError] }) error instanceof HttpError // true findCauseOf(error, NotFoundError)?.context // { userId: 42 } ``` The class is chosen by the payload's `name`, looked up among the built-in error classes and whatever is passed in `classes`; a class of your own wins over a built-in of the same name. Then the serialized fields are put back — `name`, `code`, `context`, `stack`, and any own properties the payload carried — and the cause chain is rebuilt the same way. **`classes` is not optional in practice.** Without it, a payload named `NotFoundError` comes back as an `ExtendedError` that keeps the name, so it still reads correctly in a log but `instanceof NotFoundError` is false. List every class the payload is expected to contain. Class names are protocol keys. Keep custom names stable and unique within `classes` unless one is deliberately replacing a built-in; when two entries have the same name, the later one wins. With no serialized stack the result has none, rather than one pointing at `deserializeError` instead of at the failure. A real `Error` passes through untouched, and a value that is not error-shaped goes through [`toError`](https://rxova.org/packages/ts-extended-errors/guides/unknown-values.md). | Option | Default | What it does | | ---------- | ------- | ---------------------------------------------------------------- | | `classes` | `[]` | Classes to rebuild by name, in addition to the built-ins | | `maxDepth` | `8` | How far down the chain to rebuild; below it, causes stay as sent | ### Trust `deserializeError` constructs a class because the payload said to. Only list classes in `classes` that you are willing to have constructed from that input, and treat a payload from outside your system as the untrusted data it is — the same care you would give `JSON.parse` output that decides control flow. The payload's `code`, `context` and other restored fields are not validated. ## Limits `maxDepth` bounds the chain, and `maxAggregatedErrors` bounds the width of an `AggregateError`'s `errors` across the whole output — `Promise.any` over a thousand requests rejects with a thousand errors, each with a chain of its own. Errors cut by that budget are counted in `errorsOmitted`, and that count survives a further round trip, so a log line says how much is missing rather than silently claiming that was all of it. Both are described in more detail, along with the cycle handling, in [Limits and trust](https://rxova.org/packages/ts-extended-errors/under-the-hood/limits.md). ## A worked round trip ```ts // producer.ts import { serializeError } from 'ts-extended-errors' await queue.push(JSON.stringify({ jobId, error: serializeError(cause) })) ``` ```ts // consumer.ts import { deserializeError, findCauseOf } from 'ts-extended-errors' import { RateLimitedError } from './errors.js' const { jobId, error: payload } = JSON.parse(message) as { jobId: string; error: unknown } const error = deserializeError(payload, { classes: [RateLimitedError] }) const limited = findCauseOf(error, RateLimitedError) if (limited) return retry(jobId, limited.context?.retryAfterMs ?? 1_000) await deadLetter(jobId, error) ``` The consumer branches on a class, with typed context, on an error that was created in a different process. --- # Working with unknown values Source: https://rxova.org/packages/ts-extended-errors/guides/unknown-values/ ```ts try { await run() } catch (thrown) { // `thrown` is `unknown`. It may be an Error. It may be a string, a plain // object, `null`, or a rejected promise's value from a library you do not // control. } ``` Every function in this package accepts `unknown` for that reason. These four are the ones whose whole job is dealing with it. ## `toError(value)` Returns a real `Error` for any value. ```ts import { toError } from 'ts-extended-errors' logger.error(toError(thrown)) ``` Errors pass through unchanged — wrapping one would bury the stack that says where it came from. A value that is error-shaped but not an `Error` — from another realm, or through `structuredClone` or a JSON round trip — is rebuilt as an `ExtendedError` keeping the original's `name` and `stack`, with the original as its `cause`, since it may carry fields this package knows nothing about. Anything else becomes an `ExtendedError` whose message is `describeValue(value)`, again with the original as its `cause`. `throw 'nope'` reaches your logger as `Error: nope` rather than vanishing. If a getter or proxy trap throws while the value is inspected, that metadata is treated as unavailable. Even a revoked proxy becomes an `Error` instead of replacing the original failure with the inspection exception. ## `isErrorLike(value)` True for anything with a string `message` — the deliberately loose check, which is what recognises an error that has crossed a realm or a clone boundary and so fails `instanceof Error`. ```ts if (isErrorLike(payload)) { // payload.message is a string } ``` Loose enough that `{ message: 'Not found', status: 404 }` passes. That is the intended behaviour for deciding whether something is worth serializing as an error; it is not a security check. A value whose `message` getter or proxy trap throws returns `false`. ## `isExtendedError(value)` The strict counterpart: `value instanceof ExtendedError`, typed as a type guard. ```ts if (isExtendedError(thrown)) { thrown.code // string | undefined thrown.context // ErrorContext | undefined } ``` It is false for an instance produced by a second copy of this package in the same process. That is the honest answer — two copies are two distinct classes — and `serializeError` is the path that tolerates it. Note that a class built with a non-`ExtendedError` `base` is also false here, even though it carries the same members. See [Defining errors](https://rxova.org/packages/ts-extended-errors/guides/defining-errors.md#other-bases). ## `describeValue(value)` A one-line string for any value, for when you need a message rather than an error. ```ts describeValue('nope') // 'nope' describeValue(404) // '404' describeValue(10n) // '10n' describeValue({ a: 1 }) // '{"a":1}' describeValue(undefined) // 'undefined' ``` A value JSON cannot write — a cycle, a `toJSON` that throws — is described by its string tag (`'[object Object]'`) rather than costing you the throw. If the value refuses string-tag inspection too, the fallback is `''`. ## A complete handler ```ts import { findCauseOf, toError } from 'ts-extended-errors' import { NotFoundError, RateLimitedError } from './errors.js' export function handle(thrown: unknown) { const limited = findCauseOf(thrown, RateLimitedError) if (limited) return respond(429, { retryAfterMs: limited.context?.retryAfterMs }) const notFound = findCauseOf(thrown, NotFoundError) if (notFound) return respond(404, { id: notFound.context?.id }) logger.error(toError(thrown)) return respond(500) } ``` No `instanceof` on the raw binding, no `thrown.message` read off an `unknown`, and a string throw from three dependencies down still reaches the logger as an `Error`. --- # API reference Source: https://rxova.org/packages/ts-extended-errors/reference/api/ Everything below is exported from the package root. There are no subpath exports other than `./package.json`. ```ts import { ExtendedError, isExtendedError, defineError, causeChain, rootCause, findCause, findCauseOf, hasCauseOf, serializeError, deserializeError, toError, isErrorLike, describeValue, } from 'ts-extended-errors' ``` ## Exports | Export | Kind | What it does | | ------------------ | -------- | --------------------------------------------------------------------- | | `ExtendedError` | class | Base class: `name`, `code`, `context`, `cause`, `stack`, `toJSON()` | | `isExtendedError` | function | `value instanceof ExtendedError`, as a type guard | | `defineError` | function | Returns a new error class | | `causeChain` | function | `[error, error.cause, …]`, outermost first | | `rootCause` | function | The last value in the chain | | `findCause` | function | The first value in the chain a predicate accepts | | `findCauseOf` | function | The first instance of a class in the chain, typed as that class | | `hasCauseOf` | function | Whether the chain contains an instance of a class | | `serializeError` | function | A JSON-safe object for any thrown value | | `deserializeError` | function | Rebuilds `serializeError` output as instances of the original classes | | `toError` | function | An `Error` for any value; errors are returned unchanged | | `isErrorLike` | function | Whether a value is an object with a string `message` | | `describeValue` | function | A one-line string for any value | The exported types are listed separately in [Types](https://rxova.org/packages/ts-extended-errors/reference/types.md). ## `ExtendedError` ```ts class ExtendedError extends Error { static readonly code: string | undefined constructor(message: string, options?: ExtendedErrorOptions) } ``` | Option | Type | Sets | | --------- | --------- | ----------------------------------------------- | | `cause` | `unknown` | `error.cause`, the native `Error` cause | | `context` | `Context` | `error.context`, typed by the class's `Context` | | Member | Value | | ---------- | ----------------------------------------------------------------------- | | `name` | The name of the class that was constructed | | `code` | The constructed class's static `code`, or `undefined` | | `context` | `options.context`, or `undefined` | | `cause` | `options.cause`. The property exists only when a cause was passed | | `stack` | Starts at the line that created the error, not inside the constructor | | `toJSON()` | `serializeError(this)`, so `JSON.stringify(error)` includes every field | Declare `code` as a `static override readonly` field in a subclass. See [Subclassing `Error`](https://rxova.org/packages/ts-extended-errors/under-the-hood/subclassing.md) for what the constructor does and why. ## `isExtendedError(value)` ```ts function isExtendedError(value: unknown): value is ExtendedError ``` `instanceof`, so it is false across two copies of the package in one process, and false for a class built on a base that is not an `ExtendedError`. ## `defineError(name, options?)` ```ts function defineError( name: string, options?: DefineErrorOptions, ): ExtendedErrorConstructor ``` | Option | Type | Effect | | --------- | ------------------------------ | ------------------------------------------------------------------- | | `code` | `string` | The class's `code`. Omitted, it inherits the base's | | `base` | an error class | The class to extend. Defaults to `ExtendedError` | | `message` | `(context: Context) => string` | Writes the message from context; the throw site passes only options | Four overloads cover the combinations: with and without `message`, on an `ExtendedError` base and on any other error class. With `message`, the returned class is a [`MessageErrorConstructor`](https://rxova.org/packages/ts-extended-errors/reference/types.md#messageerrorconstructor) — `new Class({ context })`, and still `new Class(message, options)` when a string comes first. Each call returns a new class. Call it at module scope, once per class. See [Defining errors](https://rxova.org/packages/ts-extended-errors/guides/defining-errors.md) for worked examples of each shape. ## Cause chain ```ts function causeChain(error: unknown): unknown[] function rootCause(error: unknown): unknown function findCause( error: unknown, predicate: (candidate: unknown) => candidate is T, ): T | undefined function findCause(error: unknown, predicate: (candidate: unknown) => boolean): unknown function findCauseOf( error: unknown, constructor: abstract new (...args: never[]) => T, ): T | undefined function hasCauseOf( error: unknown, constructor: abstract new (...args: never[]) => unknown, ): boolean ``` `causeChain` yields `error` first, stops at a primitive, and stops on a value already in the chain, so a cycle terminates. Everything else is built on it. See [Cause chains](https://rxova.org/packages/ts-extended-errors/guides/cause-chains.md). ## `serializeError(value, options?)` ```ts function serializeError( value: unknown, options: SerializeErrorOptions & { includeOwnProperties: true }, ): SerializedErrorWithProperties function serializeError(value: unknown, options?: SerializeErrorOptions): SerializedError ``` | Option | Type | Default | Effect | | ---------------------- | --------- | ------- | ------------------------------------------------------- | | `includeStack` | `boolean` | `true` | Include `stack` | | `maxDepth` | `number` | `8` | How far down the `cause` chain to walk | | `maxAggregatedErrors` | `number` | `10` | `AggregateError` `errors` kept, across the whole output | | `includeOwnProperties` | `boolean` | `false` | Also copy the error's own enumerable fields | Accepts any value. A non-error is returned as `{ name: typeof value, message: describeValue(value) }`. `context` is copied through a JSON round trip. See [Serialization](https://rxova.org/packages/ts-extended-errors/guides/serialization.md). ## `deserializeError(value, options?)` ```ts function deserializeError(value: unknown, options?: DeserializeErrorOptions): Error ``` | Option | Type | Default | Effect | | ---------- | ----------------------- | ------- | ---------------------------------------------- | | `classes` | `readonly ErrorClass[]` | `[]` | Classes to rebuild by name, plus the built-ins | | `maxDepth` | `number` | `8` | How far down the chain to rebuild | A real `Error` is returned untouched; a value that is not error-shaped goes through `toError`. An unmatched name becomes an `ExtendedError` keeping that name. ## `toError(value)` ```ts function toError(value: unknown): Error ``` ## `isErrorLike(value)` ```ts function isErrorLike(value: unknown): value is Error ``` ## `describeValue(value)` ```ts function describeValue(value: unknown): string ``` All three are covered in [Working with unknown values](https://rxova.org/packages/ts-extended-errors/guides/unknown-values.md). --- # Types Source: https://rxova.org/packages/ts-extended-errors/reference/types/ Type-only exports, all from the package root. ```ts import type { ErrorContext, ExtendedErrorOptions, SerializedError, SerializedErrorWithProperties, SerializeErrorOptions, DeserializeErrorOptions, DefineErrorOptions, ExtendedErrorConstructor, MessageErrorConstructor, ExtendedErrorMembers, ErrorClass, } from 'ts-extended-errors' ``` ## `ErrorContext` ```ts type ErrorContext = Readonly> ``` The constraint on every `Context` type parameter. Keep what you put in it serializable — `serializeError` copies it through a JSON round trip, so a `Date` arrives as a string and a `Map` as `{}`. ## `ExtendedErrorOptions` ```ts interface ExtendedErrorOptions { readonly cause?: unknown readonly context?: Context } ``` The second argument of every error constructor here. ## `SerializedError` ```ts interface SerializedError { readonly name: string readonly message: string readonly code?: string | undefined readonly stack?: string | undefined readonly context?: ErrorContext | undefined readonly cause?: SerializedError | undefined readonly errors?: readonly SerializedError[] | undefined readonly errorsOmitted?: number | undefined } ``` What `serializeError` returns, and what `JSON.stringify` produces for any error in this package. `code` and `context` appear only when the error carries them; `stack` is omitted under `includeStack: false`; `errors` and `errorsOmitted` appear only for an `AggregateError`. ## `SerializedErrorWithProperties` ```ts interface SerializedErrorWithProperties extends SerializedError { readonly cause?: SerializedErrorWithProperties | undefined readonly errors?: readonly SerializedErrorWithProperties[] | undefined readonly [field: string]: unknown } ``` What `serializeError` returns with `includeOwnProperties: true`: the fixed fields plus an index signature for whatever else the error carried, down the whole chain. ## `SerializeErrorOptions` and `DeserializeErrorOptions` The option bags of the two functions. Their fields are tabulated in the [API reference](https://rxova.org/packages/ts-extended-errors/reference/api.md#serializeerrorvalue-options). ## `DefineErrorOptions` ```ts interface DefineErrorOptions { readonly code?: string readonly base?: ExtendedErrorConstructor } ``` The options of `defineError` in its plain form. The overloads that take a `message`, or a `base` that is not an `ExtendedError`, declare their own object types inline. ## `ExtendedErrorConstructor` ```ts interface ExtendedErrorConstructor< Context extends ErrorContext = ErrorContext, Instance extends Error = ExtendedError, > { new (message: string, options?: ExtendedErrorOptions): Instance readonly prototype: Instance readonly code: string | undefined } ``` The class `defineError` returns. ## `MessageErrorConstructor` ```ts interface MessageErrorConstructor< Context extends ErrorContext = ErrorContext, Instance extends Error = ExtendedError, > { new ( ...options: Partial extends Context ? [options?: ExtendedErrorOptions] : [options: ExtendedErrorOptions & { readonly context: Context }] ): WithContext new (message: string, options?: ExtendedErrorOptions): WithContext readonly prototype: WithContext readonly code: string | undefined } ``` The class `defineError` returns when given a `message`. The conditional tuple is what makes `context` required when its type has required fields and the whole argument optional when it does not. The second signature keeps the class an `ErrorClass`, which is what lets `deserializeError` rebuild it and what lets it be the `base` of another class. `WithContext` applies the same condition to the instance, so where the throw site has to pass a context the instance's is typed as present rather than `Context | undefined`: ```ts const found = findCauseOf(thrown, InvalidDateError) found?.context.value // string — no second `?.` for a case that cannot happen ``` It is not exported; it exists so that the call site's guarantee and the instance type cannot drift apart. The `(message, options)` constructor is the one path that could build an instance without a context, and it defaults one to `{}` rather than requiring the argument — requiring it would stop the class being an `ErrorClass`. See [Defining errors](https://rxova.org/packages/ts-extended-errors/guides/defining-errors.md#messages-written-from-context). ## `ExtendedErrorMembers` ```ts interface ExtendedErrorMembers { readonly name: string readonly code: string | undefined readonly context: Context | undefined toJSON(): SerializedError } ``` What every class `defineError` returns adds to its instances, whatever it extends. It is what makes the return type of a non-`ExtendedError` base readable: `RangeError & ExtendedErrorMembers`. ## `ErrorClass` ```ts type ErrorClass = new ( message: string, options?: ErrorOptions, ) => Instance ``` Any error class whose constructor takes `(message, options)` the way the built-ins do. It is the type of `base` on the non-`ExtendedError` overloads, and of the entries in `deserializeError`'s `classes`. --- # Limits and trust Source: https://rxova.org/packages/ts-extended-errors/under-the-hood/limits/ Everything here runs on a path that is already handling a failure. The bounds below exist so that a serializer cannot become the incident. ## Depth `serializeError` walks the `cause` chain to `maxDepth`, 8 by default. Below that limit the cause is simply not included. Chains are influenced by input more often than they look — a parse error wrapping a network error wrapping a retry wrapper — so an unbounded walk in a log path is a liability. `deserializeError` has the same limit on the way back; below it, causes are left as they arrived. ## Width An `AggregateError` carries a list. `Promise.any` over a thousand requests rejects with a thousand errors, each of which may have a chain of its own, so `maxAggregatedErrors` (10 by default) bounds the total _across the whole output_ rather than per error — a per-error limit would multiply through nesting instead of capping anything. What the budget cuts is counted in `errorsOmitted`. That count is read back by `deserializeError` and carried forward by the next `serializeError`, so a payload that has been through two hops still says how many are missing rather than implying that was all of them. ## Cycles `causeChain` stops when it reaches a value already on the chain, so `a.cause = b; b.cause = a` yields `[a, b]`. `serializeError` tracks the errors on the _current path_ rather than the whole traversal. The distinction matters: the same error appearing under two different branches is legitimate and should be serialized twice, while the same error appearing above itself is a cycle and must stop. An error that is its own cause therefore stops immediately. A cycle inside `context` or inside an own property is handled differently — that value simply cannot go through `JSON.stringify`, so it is described by its string tag instead of costing the whole log line. ## Detachment `context` is copied field by field through a JSON round trip taken at serialization time, not held by reference. Three consequences: - The result shares nothing with the error, so a redactor can edit the payload without mutating the error, and a later mutation of the error cannot change what was logged. - `JSON.stringify` on the result cannot throw. - Types are lost. A `Date` becomes an ISO string, a `BigInt` becomes `'10n'`, a `Map` becomes `{}`. One field that JSON cannot write is described on its own rather than costing the rest of the context. ## Across realms A worker, a `vm` context, or a second bundled copy of a library all produce errors for which `instanceof Error` is false in your realm. `serializeError` and `isErrorLike` work on them anyway — the test is a string `message`, plus a string-tag check where distinguishing a real error from error-shaped data matters. `isExtendedError` does not, and should not: two copies of the package are two distinct classes, and saying otherwise would be a lie with consequences. The path that works across a realm boundary is to serialize on one side and deserialize on the other, with `classes` naming the local classes. ## Trusting a payload `deserializeError` chooses a class by the payload's `name` and constructs it. Two things follow: - **List only classes you are willing to have constructed from that input.** A name matching nothing in `classes` or the built-ins becomes an `ExtendedError` keeping the name, which is the safe default; an entry in `classes` is an explicit permission. - **A deserialized error's `code` and `context` are attacker-controlled** if the payload was. Treat them like any other parsed input that reaches a branch — the same care you would give `JSON.parse` output used in control flow. `includeOwnProperties` is the mirror image on the way out: it copies whatever else the error class put on the instance, which may be a request, a token or a user record. It is off by default for that reason, and it belongs in a log you control rather than in a response body. ## What is not bounded The `message` of a single error, the size of one `context` field, and the number of own properties under `includeOwnProperties`. If any of those can be large in your system, cap them before they reach the serializer — this package will faithfully write what it was given. --- # Subclassing Error Source: https://rxova.org/packages/ts-extended-errors/under-the-hood/subclassing/ `ExtendedError`'s constructor is about fifteen lines. Each one exists for a specific, reproducible failure. ```ts constructor(message: string, options: ExtendedErrorOptions = {}) { super(message, superOptions(options)) const constructedBy = new.target Object.setPrototypeOf(this, constructedBy.prototype) this.name = constructedBy.name this.code = constructedBy.code this.context = options.context captureStack(this, constructedBy) } ``` ## `new.target`, not `this.constructor` `new.target` is the class the `new` expression actually named. For `new ConfigError(…)` it is `ConfigError`, even though this code lives on the base class — which is what lets a subclass get its own name and its own `code` without restating either. ## `Object.setPrototypeOf` Compile a class down to ES5 — directly, or through a bundler that targets an older baseline — and `super()` returns a _fresh_ `Error` object rather than initialising `this`. The prototype chain is lost with it, so `instanceof ConfigError` is false. Restoring the prototype costs one property write in the constructor and is unfixable at the call site, which is the trade that decides it. It is the single most common surprise when subclassing `Error` in TypeScript, and the one most likely to be discovered in production, since it only appears under a build configuration the tests may not use. ## `name` `Error`'s `name` comes from the prototype and stays `'Error'` for a subclass that does not set it. Every log line, every `toString()`, every serialized payload then says `Error` for a class you named carefully. Setting it from `new.target.name` means a subclass reports itself, and a class declared with `defineError` reports the name passed to the factory — the factory redefines the class's own `name` for exactly that reason, since a class expression would otherwise be called `Defined`. ## `code` Declared `static` so it is written once per class rather than at every throw site, and copied onto the instance so it survives serialization: a plain object produced from the error has no class to read a static off. A class that declares no `code` of its own gets its base's, which is what you want for a leaf that callers distinguish by `instanceof` rather than by code. ## `cause`, only when there was one ```ts const superOptions = (options) => ('cause' in options ? { cause: options.cause } : undefined) ``` `super(message, { cause: undefined })` still _defines_ the property. `'cause' in error` would then be true for an error that has none — and that is precisely the check a chain walker uses to decide where to stop. So the options object is forwarded only when the caller actually passed a cause. ## `Error.captureStackTrace` Without it, the top frame of the stack is the constructor, and the line that actually threw is one frame down. Passing the constructor as the second argument omits its own frames, so the trace starts at the throw site. It is V8-only — JavaScriptCore and SpiderMonkey have no such method — hence the `typeof` guard. On those engines the stack is whatever the engine produced, which is still correct, just one frame noisier. ## `toJSON` `message` and `stack` are non-enumerable on `Error`, so `JSON.stringify(new Error('x'))` is `{}`. Defining `toJSON` as `serializeError(this)` is what makes an error usable with any logger that stringifies its input, without that logger knowing anything about this package. ## Classes on a base that is not an `ExtendedError` `defineError(name, { base: RangeError })` cannot extend `ExtendedError` as well — a class has one parent. So the factory builds an equivalent constructor on top of the given base: the same `new.target` reads, the same prototype restore, the same stack capture, the same `toJSON`. The result carries every member an `ExtendedError` has, but `instanceof ExtendedError` and `isExtendedError` are false for it. That is the accurate answer rather than a convenient one: it is a `RangeError` that happens to carry the same fields.