Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
187 changes: 187 additions & 0 deletions src/error/README.md
Original file line number Diff line number Diff line change
@@ -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<DomainDefaults>(name: string, options?: DomainOptions<DomainDefaults>): ErrorDomain<DomainDefaults>
```

| 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: Options): { [K in keyof Options]: new (context) => DefinedErrorInstance }
```

| Parameter | Type | Description |
| --------- | ------------------------------------ | -------------------------------------------------------------- |
| `options` | `Record<string, DefineErrorOptions>` | 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<string, unknown>;
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.
98 changes: 98 additions & 0 deletions src/error/error.lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { isDev } from "../lib/is-dev.ts";

export type Context = Record<string, unknown>;

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<string, DefinedErrorConstructor>();

export const mergeContext = (
...sources: ReadonlyArray<Context | undefined>
): 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 = <ErrorClass extends DefinedErrorConstructor>(
ErrorClassConstructor: ErrorClass,
serialized: SerializedError,
): InstanceType<ErrorClass> => {
const instance = Object.create(
ErrorClassConstructor.prototype,
) as InstanceType<ErrorClass>;

Object.assign(instance, {
name: serialized.name,
message: serialized.message,
stack: serialized.stack,
code: serialized.code,
context: serialized.context,
});

return instance;
};
Loading
Loading