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
64 changes: 60 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,54 @@ new Unauthorized("Custom message");
new Unauthorized("Custom message", { cause: underlyingError });
```

### Function messages

When a `{param}` template isn't expressive enough, pass a function as the
message. Its parameter must be an object type, which defines the typed params
required by the constructor, and you control the formatting:

```typescript
const TooMany = createErrorClass({
code: "TOO_MANY_ITEMS",
message: (params: { items: number[] }) =>
`Found ${params.items.length} items, expected fewer`,
status: 400,
});

// Params required — typed as { items: number[] } from the function signature
new TooMany({ items: [1, 2, 3] });
new TooMany({ items: [1, 2, 3] }, { cause: underlyingError });
new TooMany({ items: "nope" }); // ❌ wrong param type, caught at compile time
```

The parameter must be an object — `(n: number) => ...` is a compile-time error,
since the constructor passes params as an object. Declare a required parameter
(`(params: { … }) => …`) rather than a defaulted one (`(params = {}) => …`); a
default makes the params optional at the type level.

A zero-argument function behaves like an error without template parameters:

```typescript
const Unauthorized = createErrorClass({
code: "UNAUTHORIZED",
message: () => "Access denied",
status: 401,
});

new Unauthorized();
```

The optional custom message passed to the constructor is always a plain string
(`new TooMany("Custom message")`) — the function only produces the default
message.

As with `{param}` templates, `cause` is a reserved parameter name: a function
message whose params include a `cause` key is a compile-time error. This keeps
`cause` meaning only "the error's cause" (passed as the second constructor
argument), with no shadowing. Because a function's parameter types are erased at
runtime, this is enforced at compile time only — unlike the `{cause}` template
check, which also throws at runtime.

## Error instance properties

Every error instance has the standard `Error` properties plus:
Expand Down Expand Up @@ -223,18 +271,26 @@ compatible with all standard tooling.

## Reserved parameter names

The parameter name `cause` is reserved and cannot be used in message templates.
This is enforced at both compile time and runtime:
The parameter name `cause` is reserved and cannot be used as a message
parameter, so it only ever refers to the error's `cause` (the second constructor
argument). For string templates this is enforced at both compile time and
runtime; for function messages, whose param types are erased at runtime, it is
enforced at compile time only.

```typescript
// ❌ Compile error at definition time
// ❌ Compile error at definition time + runtime throw
const Bad = createErrorClass({
code: "BAD",
message: "Failed because {cause}",
status: 500,
});

// ❌ Runtime error — throws immediately
// ❌ Compile error at definition time (function params)
const AlsoBad = createErrorClass({
code: "BAD",
message: (params: { cause: string }) => `Failed because ${params.cause}`,
status: 500,
});
```

## Type safety
Expand Down
12 changes: 12 additions & 0 deletions example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ e.status; // 404
e.message; // string
e.cause; // unknown

// Function message — params are inferred from the function signature
const TooMany = createErrorClass({
code: "TOO_MANY_ITEMS",
message: (params: { items: number[] }) =>
`Found ${params.items.length} items, expected fewer`,
status: 400,
});
new TooMany({ items: [1, 2, 3] }); // ✅ params typed as { items: number[] }
new TooMany({ items: [1, 2, 3] }, { cause: new Error() }); // ✅
// @ts-expect-error Wrong param type — should error
new TooMany({ items: "nope" }); // ❌

// createErrorClassesByName — keyed by PascalCase name
const byName = createErrorClassesByName([
{
Expand Down
117 changes: 102 additions & 15 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ type PascalFromScreamingSnake<T extends string> =

type ForbiddenParamKeys = "cause";

// Single source of truth for the compile-time hints surfaced on a definition's
// `message` field, shared across the string and function paths.
type ReservedCauseError =
"Error: message cannot use reserved parameter name 'cause'";
type NonObjectParamsError =
"Error: function message params must be an object type";

type ValidateMessage<T extends string> = string extends T
? T
: ExtractParams<T> & ForbiddenParamKeys extends never
? T
: `Error: message template cannot use reserved parameter name 'cause'`;
: ReservedCauseError;

type ParamsFor<T extends string> =
ExtractParams<T> extends never
Expand All @@ -26,7 +33,78 @@ type ParamsFor<T extends string> =
type HasParams<T extends string> =
ExtractParams<T> extends never ? false : true;

export type ErrorDefinition<TMessage extends string = string> = {
// A message can be a static template string or a function that receives the
// (typed) params object and returns the message string.
type MessageFn = (params: any) => string;

// Params accepted by an error's default constructor, derived from the message:
// inferred from the function's first parameter, or extracted from a template.
type MessageParams<TMessage> =
TMessage extends (params: infer P) => string
? P
: TMessage extends string
? ParamsFor<TMessage>
: never;

// Whether the default message requires a params object. A zero-arg (or
// optional-arg) function message behaves like a no-param error.
type MessageHasParams<TMessage> =
TMessage extends MessageFn
? TMessage extends () => string
? false
: true
: TMessage extends string
? HasParams<TMessage>
: false;

// The explicitly-declared keys of T, dropping any index signature. Lets us spot
// an explicit `cause` member even when it coexists with `Record<string, …>`.
type ExplicitKeys<T> = {
[K in keyof T as string extends K
? never
: number extends K
? never
: symbol extends K
? never
: K]: T[K];
};

// True when a message uses the reserved `cause` parameter name — either as a
// `{cause}` placeholder in a string template or as an explicit key on a function
// message's params. Reserving it for both keeps `cause` meaning only "the
// error's cause". (Function params are erased at runtime, so for them this is
// compile-time only.)
type MessageUsesReservedKey<TMessage> = TMessage extends string
? ExtractParams<TMessage> & ForbiddenParamKeys extends never
? false
: true
: TMessage extends (params: infer P) => string
? ForbiddenParamKeys extends keyof ExplicitKeys<P>
? true
: false
: false;

// True when a function message's params are not an object type (e.g. a primitive
// like `(n: number) => string`), which the runtime cannot route as params.
type MessageParamsInvalid<TMessage> = TMessage extends () => string
? false
: TMessage extends (params: infer P) => string
? P extends Record<string, unknown>
? false
: true
: false;

// The compile-time problem (if any) with a message, as a branded error string.
type MessageProblem<TMessage> =
MessageUsesReservedKey<TMessage> extends true
? ReservedCauseError
: MessageParamsInvalid<TMessage> extends true
? NonObjectParamsError
: never;

export type ErrorDefinition<
TMessage extends string | MessageFn = string | MessageFn,
> = {
code: string;
message: TMessage;
status: number;
Expand All @@ -40,14 +118,14 @@ type ErrorInstance<Def extends ErrorDefinition> = Error & {
name: PascalFromScreamingSnake<Def["code"]>;
};

export type ErrorConstructor<Def extends ErrorDefinition> = (HasParams<
export type ErrorConstructor<Def extends ErrorDefinition> = (MessageHasParams<
Def["message"]
> extends true
? {
// Default message — params required
new (params: ParamsFor<Def["message"]>): ErrorInstance<Def>;
new (params: MessageParams<Def["message"]>): ErrorInstance<Def>;
new (
params: ParamsFor<Def["message"]>,
params: MessageParams<Def["message"]>,
opts: ErrorOpts,
): ErrorInstance<Def>;
// Custom message — params optional
Expand All @@ -72,24 +150,33 @@ export type ErrorConstructor<Def extends ErrorDefinition> = (HasParams<
name: PascalFromScreamingSnake<Def["code"]>;
};

type ValidateDefinition<Def extends ErrorDefinition> = ExtractParams<
Def["message"]
> &
ForbiddenParamKeys extends never
? ErrorConstructor<Def>
: "Error: message template cannot use reserved parameter name 'cause'";
// The expected `message` type when a definition has a problem — a branded error
// string. For string templates it flows through ValidateMessage; for function
// messages (not assignable to a string) it surfaces the error on the field.
type ExpectedMessage<TMessage> = TMessage extends string
? ValidateMessage<TMessage>
: MessageProblem<TMessage>;

type ValidateDefinition<Def extends ErrorDefinition> =
[MessageProblem<Def["message"]>] extends [never]
? ErrorConstructor<Def>
: MessageProblem<Def["message"]>;

export function createErrorClass<const Def extends ErrorDefinition>(
def: ExtractParams<Def["message"]> & ForbiddenParamKeys extends never
def: [MessageProblem<Def["message"]>] extends [never]
? Def
: ErrorDefinition & { message: ValidateMessage<Def["message"]> },
: Omit<ErrorDefinition, "message"> & {
message: ExpectedMessage<Def["message"]>;
},
): ValidateDefinition<Def>;

type ValidateDefinitions<Defs extends ReadonlyArray<ErrorDefinition>> = {
[K in keyof Defs]: Defs[K] extends ErrorDefinition
? ExtractParams<Defs[K]["message"]> & ForbiddenParamKeys extends never
? [MessageProblem<Defs[K]["message"]>] extends [never]
? Defs[K]
: ErrorDefinition & { message: ValidateMessage<Defs[K]["message"]> }
: Omit<ErrorDefinition, "message"> & {
message: ExpectedMessage<Defs[K]["message"]>;
}
: Defs[K];
};

Expand Down
48 changes: 33 additions & 15 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,7 @@ function _inspect(value, depth, seen) {
result = "[]";
} else {
const items = _truncated(
value
.slice(0, MAX_ITEMS)
.map((v) => _inspect(v, depth - 1, seen)),
value.slice(0, MAX_ITEMS).map((v) => _inspect(v, depth - 1, seen)),
value.length,
);
result = `[ ${items.join(", ")} ]`;
Expand Down Expand Up @@ -141,27 +139,32 @@ export function createErrorClass(definition) {
const { code, message: defaultMessage, status } = definition;
const className = toPascalCase(code);

validateMessage(code, defaultMessage);
const isFunctionMessage = typeof defaultMessage === "function";

const hasTemplateParams = /\{\w+\}/.test(defaultMessage);
if (!isFunctionMessage) {
validateMessage(code, defaultMessage);
}

const ErrorKlass = class extends Error {
code = code;
status = status;
name = className;

constructor(messageOrParams, paramsOrOpts, opts) {
const message =
typeof messageOrParams === "string" ? messageOrParams : defaultMessage;
const customMessage =
typeof messageOrParams === "string" ? messageOrParams : undefined;

let params, cause;

if (typeof messageOrParams === "object" && messageOrParams !== null) {
if (!hasTemplateParams && "cause" in messageOrParams) {
// No-param error: first arg is ErrorOpts
cause = messageOrParams.cause;
// First arg is a params and/or { cause } object. `cause` is a reserved
// param name, so a cause key here is always the error's cause — which
// also lets us route params without relying on the message's arity.
if ("cause" in messageOrParams) {
const { cause: extractedCause, ...rest } = messageOrParams;
cause = extractedCause;
params = Object.keys(rest).length > 0 ? rest : undefined;
} else {
// First arg is params object: new Err(params) or new Err(params, opts)
params = messageOrParams;
cause = paramsOrOpts?.cause;
}
Expand All @@ -181,10 +184,25 @@ export function createErrorClass(definition) {
}
}

super(
params ? interpolate(message, params) : message,
cause !== undefined ? { cause } : undefined,
);
// A custom message string always uses template interpolation; the
// function form only ever produces the default message.
let message;
if (customMessage !== undefined) {
message = params ? interpolate(customMessage, params) : customMessage;
} else if (isFunctionMessage) {
// The message function is user code; never let it abort construction of
// the error (which would mask the original failure). Pass an object even
// when params are absent, and fall back to the code if it throws.
try {
message = defaultMessage(params ?? {});
} catch {
message = code;
}
} else {
message = params ? interpolate(defaultMessage, params) : defaultMessage;
}

super(message, cause !== undefined ? { cause } : undefined);

if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
Expand Down
Loading
Loading