From 509aee4841d5b647f98f873b044f01d699919117 Mon Sep 17 00:00:00 2001 From: Johannes Ewald Date: Wed, 5 Aug 2026 02:28:22 +0200 Subject: [PATCH] feat(error): add namespaced, serializable error domains Adds a new `error` sub-export for defining error classes: domains are classes (so `instanceof` works against a whole domain, not just one error), error codes are namespaced via the existing `namespace` module, context merges domain defaults -> per-error defaults -> runtime context, and errors round-trip through JSON via `toJSON()`/`errors.parse()` with an `UnknownError` fallback for unregistered codes. Stack serialization defaults to `isDev` (new `src/lib/is-dev.ts` helper) and is configurable globally or per call. Co-Authored-By: Claude Sonnet 5 --- .size-limit.json | 6 + README.md | 1 + jsr.json | 1 + package.json | 1 + src/error/README.md | 187 ++++++++++++++++++++++ src/error/error.lib.ts | 98 ++++++++++++ src/error/error.test.ts | 336 ++++++++++++++++++++++++++++++++++++++++ src/error/error.ts | 258 ++++++++++++++++++++++++++++++ src/lib/is-dev.ts | 23 +++ 9 files changed, 911 insertions(+) create mode 100644 src/error/README.md create mode 100644 src/error/error.lib.ts create mode 100644 src/error/error.test.ts create mode 100644 src/error/error.ts create mode 100644 src/lib/is-dev.ts diff --git a/.size-limit.json b/.size-limit.json index 18ab8c5..0f7b440 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -59,6 +59,12 @@ "limit": "275 B", "brotli": true }, + { + "name": "@peerigon/typescript-toolkit/error", + "path": "dist/error/error.js", + "limit": "875 B", + "brotli": true + }, { "name": "@peerigon/typescript-toolkit/api", "path": "dist/api/api.js", diff --git a/README.md b/README.md index 63c0dee..93a2375 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ import { assert } from "@peerigon/typescript-toolkit/assert"; | [`no-null`](./src/no-null/README.md) | Convert between `null` and `undefined` in JSON-like values (runtime + types) | [→](./src/no-null/README.md) | | [`dedupe`](./src/dedupe/README.md) | Remove duplicate values from an array while preserving first-occurrence order | [→](./src/dedupe/README.md) | | [`emitter`](./src/emitter/README.md) | Minimal typed event emitter with payload objects per event | [→](./src/emitter/README.md) | +| [`error`](./src/error/README.md) | Namespaced, serializable error classes grouped into domains with `instanceof` | [→](./src/error/README.md) | | [`enums`](./src/enums/README.md) | Lightweight string-enum alternative for `erasableSyntaxOnly` TypeScript projects | [→](./src/enums/README.md) | | [`map-leaves`](./src/map-leaves/README.md) | Deeply map leaves in JSON-like values (mutates arrays/objects in place) | [→](./src/map-leaves/README.md) | | [`match`](./src/match/README.md) | Exhaustive pattern matching with compile-time case checks, similar to `switch` | [→](./src/match/README.md) | diff --git a/jsr.json b/jsr.json index 760e7cd..f29799c 100644 --- a/jsr.json +++ b/jsr.json @@ -16,6 +16,7 @@ "./concurrency/rate-limit": "./src/concurrency/rate-limit/rate-limit.ts", "./dedupe": "./src/dedupe/dedupe.ts", "./emitter": "./src/emitter/emitter.ts", + "./error": "./src/error/error.ts", "./enums": "./src/enums/enums.ts", "./map-leaves": "./src/map-leaves/map-leaves.ts", "./match": "./src/match/match.ts", diff --git a/package.json b/package.json index 505ea83..a8f3b13 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "./concurrency/rate-limit": "./dist/concurrency/rate-limit/rate-limit.js", "./dedupe": "./dist/dedupe/dedupe.js", "./emitter": "./dist/emitter/emitter.js", + "./error": "./dist/error/error.js", "./enums": "./dist/enums/enums.js", "./map-leaves": "./dist/map-leaves/map-leaves.js", "./match": "./dist/match/match.js", diff --git a/src/error/README.md b/src/error/README.md new file mode 100644 index 0000000..4a602e0 --- /dev/null +++ b/src/error/README.md @@ -0,0 +1,187 @@ +## `error` + +- 📦 Below 875 Bytes minified + compressed (brotli) +- ✅ Zero dependencies + +Define namespaced, serializable error classes grouped into domains. Domains are themselves classes, so `instanceof` works against a whole domain (or sub-domain), not just a single error. Errors round-trip through `JSON.stringify`/`errors.parse` without losing their class identity. + +### Basic usage + +```ts +import { errors } from "@peerigon/typescript-toolkit/error"; + +const HttpErrors = errors.domain("Http"); + +const { NotFound, Unauthorized } = HttpErrors.define({ + NotFound: { + context: { httpStatus: 404 }, + message: (context: { httpStatus: number; resource: string }) => + `${context.resource} not found`, + }, + Unauthorized: { + context: { httpStatus: 401 }, + message: "Unauthorized", + }, +}); + +const error = new NotFound({ resource: "user" }); + +error.code; // "Http.NotFound" +error.name; // "NotFound" +error.message; // "user not found" +error.context; // { httpStatus: 404, resource: "user" } +error.stack; // present, a real Error stack + +error instanceof NotFound; // true +error instanceof HttpErrors; // true — instanceof works against the whole domain +error instanceof Error; // true +``` + +### Merging context + +Context comes from three places, later ones winning on key clashes: domain defaults → per-error defaults → whatever you pass when constructing the error. + +```ts +const BillingErrors = errors.domain("Billing", { + context: { service: "billing-api" }, +}); + +const { PaymentFailed } = BillingErrors.define({ + PaymentFailed: { + context: { httpStatus: 402 }, + message: "Payment failed", + }, +}); + +const error = new PaymentFailed({ httpStatus: 500 }); + +error.context; // { service: "billing-api", httpStatus: 500 } +``` + +### Sub-domains + +```ts +const ClientErrors = HttpErrors.domain("Client"); +const { BadRequest } = ClientErrors.define({ + BadRequest: { message: "Bad request" }, +}); + +const error = new BadRequest({}); + +error.code; // "Http.Client.BadRequest" +error instanceof ClientErrors; // true +error instanceof HttpErrors; // true — still true for the parent domain +``` + +Domains are abstract — `new HttpErrors()` throws. Only errors created via `.define()` can be instantiated. + +### Serialization + +```ts +const json = JSON.stringify(error); // calls error.toJSON() automatically +const restored = errors.parse(json); // accepts a JSON string or an already-parsed object + +restored instanceof NotFound; // true, if NotFound is still registered +``` + +If the code isn't registered (e.g. it came from another service or an older deploy), `parse()` falls back to `UnknownError` instead of throwing — it still carries the original code, message, context, and stack. + +Stack traces are only serialized in dev by default (`errors.serialize.includeStack`, itself defaulting to `isDev`), so production error payloads don't leak stack traces unless you opt in: + +```ts +errors.serialize.includeStack = false; // control it globally +error.toJSON({ includeStack: true }); // or override per call +``` + +### API Reference + +#### `errors.domain(name, options?)` + +Defines a root error domain. + +```ts +errors.domain(name: string, options?: DomainOptions): ErrorDomain +``` + +| Parameter | Type | Description | +| --------- | -------------------- | ---------------------------------------------------------------------- | +| `name` | `string` | The domain's name. Used verbatim as the code prefix and the class name | +| `options` | `DomainOptions<...>` | Optional `context` defaults and `separator` | + +**Throws:** `Error` when `name` was already used for another root domain + +#### `DomainOptions` + +| Property | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------------------------------------------------ | +| `context` | `object` | `{}` | Default context merged into every error defined in this domain (and its sub-domains) | +| `separator` | `string` | `"."` | Separator between namespace segments. Only settable at the root domain | + +#### `ErrorDomain.domain(name, options?)` + +Defines a nested sub-domain, namespaced under this domain. + +```ts +domain(name: string, options?: { context?: object }): ErrorDomain<...> +``` + +Errors defined within the returned sub-domain are also `instanceof` every ancestor domain. + +#### `ErrorDomain.define(options)` + +Defines one or more error classes within this domain, keyed by code. + +```ts +define(options: Options): { [K in keyof Options]: new (context) => DefinedErrorInstance } +``` + +| Parameter | Type | Description | +| --------- | ------------------------------------ | -------------------------------------------------------------- | +| `options` | `Record` | One entry per error, keyed by code (used verbatim as the name) | + +**Returns:** An object with one generated error class per key + +**Throws:** `Error` when a code was already used within this domain + +#### `DefineErrorOptions` + +| Property | Type | Description | +| --------- | --------------------------------- | ------------------------------------------------------------------------------ | +| `context` | `object` | Define-time defaults, merged under domain defaults and over by runtime context | +| `message` | `string \| ((context) => string)` | A static message, or a function deriving one from the fully merged context | + +#### `errors.parse(serialized)` + +Reconstructs an error from a `toJSON()` snapshot, or its JSON string form. + +```ts +errors.parse(serialized: string | SerializedError): DefinedErrorInstance | UnknownError +``` + +Accepts either a JSON string (calls `JSON.parse()` on it first) or an already-parsed `SerializedError` object. Reconstructs an exact snapshot — it does not re-run the original class's constructor logic (so it can't drift from what was serialized, even if the class's `message` function has since changed). Falls back to `UnknownError` when the code isn't registered. + +#### `errors.serialize` + +```ts +errors.serialize: { includeStack: boolean } +``` + +Mutable global default for whether `toJSON()` includes the stack trace. Defaults to `isDev`. + +### Type Reference + +#### `SerializedError` + +```ts +type SerializedError = { + code: string; + name: string; + message: string; + context: Record; + stack: string | undefined; +}; +``` + +#### `UnknownError` + +A plain `Error` subclass used by `parse()` as a fallback. Carries `code` and `context` like any other defined error, but isn't tied to a specific domain. diff --git a/src/error/error.lib.ts b/src/error/error.lib.ts new file mode 100644 index 0000000..612dd4d --- /dev/null +++ b/src/error/error.lib.ts @@ -0,0 +1,98 @@ +import { isDev } from "../lib/is-dev.ts"; + +export type Context = Record; + +export type SerializedError = { + code: string; + name: string; + message: string; + context: Context; + stack: string | undefined; +}; + +export type ToJSONOptions = { + /** Whether to include the stack trace. Defaults to `serialize.includeStack`. */ + includeStack?: boolean; +}; + +export type SerializeOptions = { + /** + * Whether `toJSON()` includes the stack trace by default. Mutate this to + * control it globally; defaults to `isDev`. + */ + includeStack: boolean; +}; + +export const serialize: SerializeOptions = { + includeStack: isDev, +}; + +export type DefinedErrorInstance = Error & { + readonly code: string; + readonly context: Context; + toJSON: (options?: ToJSONOptions) => SerializedError; +}; + +type DefinedErrorConstructor = new (context: Context) => DefinedErrorInstance; + +/** + * Registered error classes by their fully qualified code, so `parse()` can + * look them up later. Codes are guaranteed unique by the namespace claim + * that produced them, so this never needs to guard against collisions + * itself. + */ +export const registry = new Map(); + +export const mergeContext = ( + ...sources: ReadonlyArray +): Context => Object.assign({}, ...sources) as Context; + +/** + * Builds the serializable snapshot of an error instance. The stack is only + * included when `options.includeStack` is true, defaulting to + * `serialize.includeStack` so that stack traces aren't leaked in production + * by default. + */ +export const buildSerializedError = ( + instance: { + code: string; + name: string; + message: string; + context: Context; + stack?: string; + }, + { includeStack = serialize.includeStack }: ToJSONOptions = {}, +): SerializedError => ({ + code: instance.code, + name: instance.name, + message: instance.message, + context: instance.context, + stack: includeStack ? instance.stack : undefined, +}); + +/** + * Reconstructs an error instance from a serialized snapshot without + * re-running the class's constructor logic (which would recompute the + * message from merged context using whatever defaults are current, and + * could drift from what was originally serialized). This guarantees an + * exact round-trip regardless of code changes between serialize and + * deserialize. + */ +export const restoreFromSnapshot = ( + ErrorClassConstructor: ErrorClass, + serialized: SerializedError, +): InstanceType => { + const instance = Object.create( + ErrorClassConstructor.prototype, + ) as InstanceType; + + Object.assign(instance, { + name: serialized.name, + message: serialized.message, + stack: serialized.stack, + code: serialized.code, + context: serialized.context, + }); + + return instance; +}; diff --git a/src/error/error.test.ts b/src/error/error.test.ts new file mode 100644 index 0000000..5f0b5df --- /dev/null +++ b/src/error/error.test.ts @@ -0,0 +1,336 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { isDev } from "../lib/is-dev.ts"; +import { errors, UnknownError } from "./error.ts"; + +describe("errors", () => { + describe("domain()", () => { + it("namespaces error codes under the domain name", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { SomethingWentWrong } = domain.define({ + SomethingWentWrong: { message: "Something went wrong" }, + }); + const error = new SomethingWentWrong({}); + + expect(error.code).toBe(`${domain.code}.SomethingWentWrong`); + }); + + it("throws when the same domain name is used twice", () => { + const name = `duplicate-domain-${Math.random()}`; + errors.domain(name); + + expect(() => errors.domain(name)).toThrow(); + }); + + it("throws when the same code is defined twice within a domain", () => { + const domain = errors.domain(`domain-${Math.random()}`); + domain.define({ NotFound: { message: "Not found" } }); + + expect(() => + domain.define({ NotFound: { message: "Not found" } }), + ).toThrow(); + }); + + it("namespaces sub-domains further and supports nesting", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const subdomain = domain.domain("sub"); + const { SomethingWentWrong } = subdomain.define({ + SomethingWentWrong: { message: "Something went wrong" }, + }); + const error = new SomethingWentWrong({}); + + expect(subdomain.code).toBe(`${domain.code}.sub`); + expect(error.code).toBe(`${domain.code}.sub.SomethingWentWrong`); + }); + + it("supports a custom separator, inherited by sub-domains and errors", () => { + const domain = errors.domain(`domain-${Math.random()}`, { + separator: "/", + }); + const subdomain = domain.domain("sub"); + const { SomeError } = subdomain.define({ + SomeError: { message: "x" }, + }); + const error = new SomeError({}); + + expect(subdomain.code).toBe(`${domain.code}/sub`); + expect(error.code).toBe(`${domain.code}/sub/SomeError`); + }); + + it("gives instanceof for the domain and all ancestor domains", () => { + const rootDomain = errors.domain(`domain-${Math.random()}`); + const subdomain = rootDomain.domain("sub"); + const { SomethingWentWrong } = subdomain.define({ + SomethingWentWrong: { message: "Something went wrong" }, + }); + const error = new SomethingWentWrong({}); + + expect(error).toBeInstanceOf(SomethingWentWrong); + expect(error).toBeInstanceOf(subdomain); + expect(error).toBeInstanceOf(rootDomain); + expect(error).toBeInstanceOf(Error); + }); + + it("doesn't give instanceof for an unrelated domain", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const otherDomain = errors.domain(`domain-${Math.random()}`); + const { SomeError } = domain.define({ SomeError: { message: "x" } }); + const error = new SomeError({}); + + expect(error).not.toBeInstanceOf(otherDomain); + }); + + it("names domain classes with the given name verbatim", () => { + // Note: domain codes are namespace segments, so they must not contain + // the "." separator — hence stripping the "0." prefix from the random + // suffix used to keep this test isolated from others. + const name = `Http${Math.random().toString(36).slice(2)}`; + const domain = errors.domain(name); + const subdomain = domain.domain("Client"); + + expect(domain.name).toBe(name); + expect(subdomain.name).toBe("Client"); + }); + + it("is abstract and cannot be instantiated directly", () => { + const Domain = errors.domain(`domain-${Math.random()}`); + const Subdomain = Domain.domain("sub"); + + expect(() => new Domain()).toThrow(); + expect(() => new Subdomain()).toThrow(); + }); + }); + + describe("define()", () => { + it("defines multiple error classes in a single call", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { NotFound, Unauthorized } = domain.define({ + NotFound: { + context: { httpStatus: 404 }, + message: (context: { httpStatus: number; resource: string }) => + `${context.resource} not found`, + }, + Unauthorized: { + context: { httpStatus: 401 }, + message: "Unauthorized", + }, + }); + const notFound = new NotFound({ resource: "user" }); + const unauthorized = new Unauthorized({}); + + expect(notFound.code).toBe(`${domain.code}.NotFound`); + expect(notFound.message).toBe("user not found"); + expect(notFound.context).toEqual({ httpStatus: 404, resource: "user" }); + + expect(unauthorized.code).toBe(`${domain.code}.Unauthorized`); + expect(unauthorized.message).toBe("Unauthorized"); + }); + + it("uses a static message", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { StaticMessage } = domain.define({ + StaticMessage: { message: "This is a static message" }, + }); + const error = new StaticMessage({}); + + expect(error.message).toBe("This is a static message"); + }); + + it("derives the message from the merged context", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { NotFound } = domain.define({ + NotFound: { + context: { httpStatus: 404 }, + message: (context: { httpStatus: number; resource: string }) => + `${context.resource} not found (${context.httpStatus})`, + }, + }); + const error = new NotFound({ resource: "user" }); + + expect(error.message).toBe("user not found (404)"); + }); + + it("merges domain defaults, define-time defaults and runtime context, with runtime winning", () => { + const domain = errors.domain(`domain-${Math.random()}`, { + context: { service: "billing-api", httpStatus: 500 }, + }); + const { SomeError } = domain.define({ + SomeError: { + context: { httpStatus: 404 }, + message: "irrelevant", + }, + }); + const error = new SomeError({ httpStatus: 400, resource: "user" }); + + expect(error.context).toEqual({ + service: "billing-api", + httpStatus: 400, + resource: "user", + }); + }); + + it("uses the given code verbatim as the name", () => { + const domain = errors.domain(`domain-${Math.random()}`); + // Casing/suffixes are entirely up to the caller — the library doesn't transform it. + const { NotFound, timeout: Timeout } = domain.define({ + NotFound: { message: "x" }, + timeout: { message: "x" }, + }); + + expect(new NotFound({}).name).toBe("NotFound"); + expect(new Timeout({}).name).toBe("timeout"); + }); + + it("has a real Error stack", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { SomeError } = domain.define({ SomeError: { message: "x" } }); + const error = new SomeError({}); + + expect(error.stack).toBeTypeOf("string"); + expect(error.stack).toContain("SomeError"); + }); + }); + + describe("toJSON() / parse()", () => { + it("serializes to a plain object with code, name, message, context and stack", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { NotFound } = domain.define({ + NotFound: { + context: { httpStatus: 404 }, + message: (context: { httpStatus: number }) => + `Not found (${context.httpStatus})`, + }, + }); + const error = new NotFound({}); + const json = error.toJSON(); + + expect(json).toEqual({ + code: error.code, + name: "NotFound", + message: "Not found (404)", + context: { httpStatus: 404 }, + stack: error.stack, + }); + }); + + it("round-trips a registered error class through JSON", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { NotFound } = domain.define({ + NotFound: { + context: { httpStatus: 404 }, + message: (context: { httpStatus: number }) => + `Not found (${context.httpStatus})`, + }, + }); + const error = new NotFound({}); + // JSON.stringify implicitly calls error.toJSON(). + const json = JSON.stringify(error); + const restored = errors.parse(json); + + expect(restored).toBeInstanceOf(NotFound); + expect(restored.code).toBe(error.code); + expect(restored.name).toBe(error.name); + expect(restored.message).toBe(error.message); + expect(restored.context).toEqual(error.context); + expect(restored.stack).toBe(error.stack); + }); + + it("accepts an already-parsed object as well as a JSON string", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { NotFound } = domain.define({ + NotFound: { message: "Not found" }, + }); + const error = new NotFound({}); + + const restored = errors.parse(error.toJSON()); + + expect(restored).toBeInstanceOf(NotFound); + expect(restored.code).toBe(error.code); + }); + + it("falls back to UnknownError for an unregistered code", () => { + const restored = errors.parse({ + code: "some.unregistered.code", + name: "SomeError", + message: "Something happened", + context: { foo: "bar" }, + stack: "SomeError: Something happened", + }); + + expect(restored).toBeInstanceOf(UnknownError); + expect(restored.code).toBe("some.unregistered.code"); + expect(restored.message).toBe("Something happened"); + expect(restored.context).toEqual({ foo: "bar" }); + }); + }); + + describe("toJSON() stack serialization", () => { + it("defaults to isDev", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { SomeError } = domain.define({ SomeError: { message: "x" } }); + const error = new SomeError({}); + + expect(error.toJSON().stack).toBe(isDev ? error.stack : undefined); + }); + + it("can be forced on explicitly", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { SomeError } = domain.define({ SomeError: { message: "x" } }); + const error = new SomeError({}); + + expect(error.toJSON({ includeStack: true }).stack).toBeTypeOf("string"); + }); + + it("can be forced off explicitly", () => { + const domain = errors.domain(`domain-${Math.random()}`); + const { SomeError } = domain.define({ SomeError: { message: "x" } }); + const error = new SomeError({}); + + expect(error.toJSON({ includeStack: false }).stack).toBeUndefined(); + }); + + it("also applies to UnknownError", () => { + const restored = errors.parse({ + code: "some.unregistered.code", + name: "SomeError", + message: "Something happened", + context: {}, + stack: "SomeError: Something happened", + }); + + expect(restored.toJSON({ includeStack: true }).stack).toBeTypeOf( + "string", + ); + expect(restored.toJSON({ includeStack: false }).stack).toBeUndefined(); + }); + }); + + describe("serialize.includeStack", () => { + afterEach(() => { + errors.serialize.includeStack = isDev; + }); + + it("defaults to isDev", () => { + expect(errors.serialize.includeStack).toBe(isDev); + }); + + it("controls the default for toJSON() when flipped globally", () => { + errors.serialize.includeStack = false; + + const domain = errors.domain(`domain-${Math.random()}`); + const { SomeError } = domain.define({ SomeError: { message: "x" } }); + const error = new SomeError({}); + + expect(error.toJSON().stack).toBeUndefined(); + }); + + it("can still be overridden per-call", () => { + errors.serialize.includeStack = false; + + const domain = errors.domain(`domain-${Math.random()}`); + const { SomeError } = domain.define({ SomeError: { message: "x" } }); + const error = new SomeError({}); + + expect(error.toJSON({ includeStack: true }).stack).toBeTypeOf("string"); + }); + }); +}); diff --git a/src/error/error.ts b/src/error/error.ts new file mode 100644 index 0000000..8203eeb --- /dev/null +++ b/src/error/error.ts @@ -0,0 +1,258 @@ +import { namespace, type Namespace } from "../namespace/namespace.ts"; +import { + buildSerializedError, + mergeContext, + registry, + restoreFromSnapshot, + serialize, + type Context, + type DefinedErrorInstance, + type SerializedError, + type ToJSONOptions, +} from "./error.lib.ts"; + +const renameClass = (Class: object, name: string): void => { + Object.defineProperty(Class, "name", { value: name, configurable: true }); +}; + +export type { + Context, + DefinedErrorInstance, + SerializedError, + SerializeOptions, + ToJSONOptions, +} from "./error.lib.ts"; + +type ContextInput< + ErrorContext extends Context, + Defaults extends Context, +> = Omit> & + Partial>>; + +export type DefineErrorOptions< + ErrorContext extends Context, + Defaults extends Partial, +> = { + /** Define-time defaults, merged under domain defaults and runtime context. */ + context?: Defaults; + /** + * A static message, or a function that derives one from the merged + * context. Needs an explicit parameter type annotation — TypeScript + * cannot infer it from the sibling `context` property inside a batch + * `define()` call (unannotated parameters would silently widen to `any`). + */ + message: string | ((context: ErrorContext) => string); +}; + +// Using `any` here (rather than `Context, Partial`) is deliberate: +// constraining to concrete type arguments would check each entry's +// `message` against the widened `Context` parameter type, which fails +// contravariance and makes inference fall back to this constraint — +// losing each entry's specific keys and context shape entirely. +type DefineErrorOptionsRecord = Record>; + +type DefinedErrorRecord< + DomainDefaults extends Context, + Options extends DefineErrorOptionsRecord, +> = { + [Key in keyof Options]: Options[Key] extends DefineErrorOptions< + infer ErrorContext, + infer Defaults + > + ? new ( + context: ContextInput, + ) => DefinedErrorInstance & { readonly context: ErrorContext } + : never; +}; + +export type ErrorDomain = (new ( + message?: string, +) => Error) & { + /** The fully qualified, namespaced code prefix for this domain. */ + readonly code: string; + + /** + * Defines a nested sub-domain. Its code is namespaced under this domain's + * code, and any `context` defaults given here are merged under this + * domain's own defaults. Errors defined within the sub-domain are also + * `instanceof` this domain. + */ + domain: >( + name: string, + options?: { context?: SubDefaults }, + ) => ErrorDomain; + + /** + * Defines one or more concrete error classes within this domain, keyed by + * code. Instances of the returned classes are also `instanceof` every + * ancestor domain. + */ + define: ( + options: Options, + ) => DefinedErrorRecord; +}; + +type ErrorBaseClass = new (message?: string) => Error; + +const createDomain = ( + name: string, + namespaceNode: Namespace, + defaults: DomainDefaults, + ParentClass: ErrorBaseClass, +): ErrorDomain => { + const fullCode = namespaceNode.toString(); + + class Domain extends ParentClass { + static readonly code = fullCode; + + constructor(message?: string) { + if (new.target === Domain) { + throw new Error( + `${name} is an abstract error domain and cannot be instantiated directly`, + ); + } + + super(message); + } + + toJSON( + this: DefinedErrorInstance, + options?: ToJSONOptions, + ): SerializedError { + return buildSerializedError(this, options); + } + + static domain>( + subName: string, + options: { context?: SubDefaults } = {}, + ): ErrorDomain { + return createDomain( + subName, + namespaceNode.claim(subName), + mergeContext(defaults, options.context) as DomainDefaults & SubDefaults, + Domain, + ); + } + + static define( + options: Options, + ): DefinedErrorRecord { + const result: Record = {}; + + for (const [code, errorOptions] of Object.entries(options)) { + const errorNamespace = namespaceNode.claim(code); + const errorFullCode = errorNamespace.toString(); + const defineDefaults = mergeContext(defaults, errorOptions.context); + + class DefinedError extends Domain { + readonly code = errorFullCode; + readonly context: Context; + + constructor(runtimeContext: Context) { + const context = mergeContext(defineDefaults, runtimeContext); + const message = + typeof errorOptions.message === "function" + ? (errorOptions.message as (context: Context) => string)( + context, + ) + : errorOptions.message; + + super(message); + this.name = code; + this.context = context; + } + } + + renameClass(DefinedError, code); + registry.set(errorFullCode, DefinedError); + + result[code] = DefinedError; + } + + return result as DefinedErrorRecord; + } + } + + renameClass(Domain, name); + + return Domain; +}; + +/** + * A fallback error used by `parse()` when a serialized error's code isn't + * registered (e.g. it came from a different service or an older version + * that no longer defines it). It still carries every field from the + * original snapshot. + */ +export class UnknownError extends Error { + readonly code: string; + readonly context: Context; + + constructor(serialized: SerializedError) { + super(serialized.message); + this.name = serialized.name || "UnknownError"; + this.code = serialized.code; + this.context = serialized.context; + this.stack = serialized.stack; + } + + toJSON(options?: ToJSONOptions): SerializedError { + return buildSerializedError(this, options); + } +} + +const claimedDomainNames = new Set(); + +export type DomainOptions = { + context?: DomainDefaults; + /** + * The separator between this domain's namespace segments, and those of + * its sub-domains and errors. Defaults to ".". Only settable at the root + * domain — sub-domains always inherit their parent's separator. + */ + separator?: string; +}; + +const defineDomain = >( + name: string, + options: DomainOptions = {}, +): ErrorDomain => { + if (claimedDomainNames.has(name)) { + throw new Error(`Domain name ${JSON.stringify(name)} is already claimed`, { + cause: { name }, + }); + } + + claimedDomainNames.add(name); + + return createDomain( + name, + namespace.define({ prefix: name, separator: options.separator }), + (options.context ?? {}) as DomainDefaults, + Error, + ); +}; + +const parse = ( + serialized: string | SerializedError, +): DefinedErrorInstance | UnknownError => { + const parsedSerialized: SerializedError = + typeof serialized === "string" ? JSON.parse(serialized) : serialized; + const RegisteredErrorClass = registry.get(parsedSerialized.code); + + if (RegisteredErrorClass === undefined) { + return new UnknownError(parsedSerialized); + } + + return restoreFromSnapshot(RegisteredErrorClass, parsedSerialized); +}; + +/** + * Utilities for defining namespaced, serializable error domains and error + * classes. + */ +export const errors = { + domain: defineDomain, + parse, + serialize, +}; diff --git a/src/lib/is-dev.ts b/src/lib/is-dev.ts new file mode 100644 index 0000000..2427a75 --- /dev/null +++ b/src/lib/is-dev.ts @@ -0,0 +1,23 @@ +declare global { + // Declaration merging requires an interface here, not a type alias. + /* eslint-disable @typescript-eslint/consistent-type-definitions */ + interface ImportMetaEnv { + readonly DEV?: boolean; + } + + interface ImportMeta { + readonly env?: ImportMetaEnv; + } + /* eslint-enable @typescript-eslint/consistent-type-definitions */ +} + +/** + * Whether we're running in a dev environment. `true` only when a bundler's + * `import.meta.env.DEV` or Node's `process.env.NODE_ENV` explicitly says so; + * `false` in every other case. Evaluated once at module load, so + * `import.meta.env.DEV` can be statically replaced and dead-code-eliminated + * by bundlers. + */ +export const isDev: boolean = + import.meta.env?.DEV === true || + (typeof process !== "undefined" && process.env["NODE_ENV"] === "development");