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: 3 additions & 3 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type ParamsFor<T extends string> =
ExtractParams<T> extends never
? never
: ExtractParams<T> & ForbiddenParamKeys extends never
? Record<ExtractParams<T>, string>
? Record<ExtractParams<T>, unknown>
: never; // <- causes error when forbidden keys are used

type HasParams<T extends string> =
Expand Down Expand Up @@ -53,11 +53,11 @@ export type ErrorConstructor<Def extends ErrorDefinition> = (HasParams<
// Custom message — params optional
new (
message: string,
params?: Record<string, string>,
params?: Record<string, unknown>,
): ErrorInstance<Def>;
new (
message: string,
params: Record<string, string>,
params: Record<string, unknown>,
opts: ErrorOpts,
): ErrorInstance<Def>;
new (message: string | undefined, opts: ErrorOpts): ErrorInstance<Def>;
Expand Down
114 changes: 113 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,121 @@ function toPascalCase(screamingSnake) {
.join("");
}

const MAX_ITEMS = 50;
const MAX_STRING = 200;

function inspect(value, depth = 4, seen = new WeakSet()) {
try {
return _inspect(value, depth, seen);
} catch {
return "{…}";
}
}

function _truncated(items, total) {
if (total > MAX_ITEMS) items.push(`… ${total - MAX_ITEMS} more`);
return items;
}

function _inspect(value, depth, seen) {
if (value === null) return "null";
if (value === undefined) return "undefined";
const t = typeof value;
if (t === "string")
return value.length > MAX_STRING
? `'${value.slice(0, MAX_STRING)}…'`
: `'${value}'`;
if (t === "number" || t === "boolean") return String(value);
if (t === "symbol") return value.toString();
if (t === "bigint") return `${value}n`;
if (t === "function") return `[Function: ${value.name || "anonymous"}]`;
// Non-recursive object types — always show regardless of depth
if (value instanceof Date)
return isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
if (value instanceof RegExp) return value.toString();
if (value instanceof Error) return `${value.name}: ${value.message}`;
// Depth/circular checks for recursive types
if (depth < 0) return Array.isArray(value) ? "[…]" : "{…}";
if (seen.has(value)) return "[Circular]";
seen.add(value);
let result;
if (Array.isArray(value)) {
if (value.length === 0) {
result = "[]";
} else {
const items = _truncated(
value
.slice(0, MAX_ITEMS)
.map((v) => _inspect(v, depth - 1, seen)),
value.length,
);
result = `[ ${items.join(", ")} ]`;
}
} else if (value instanceof Map) {
if (value.size === 0) {
result = "Map(0) {}";
} else {
const items = [];
let i = 0;
value.forEach((v, k) => {
if (i++ < MAX_ITEMS)
items.push(
`${_inspect(k, depth - 1, seen)} => ${_inspect(v, depth - 1, seen)}`,
);
});
_truncated(items, value.size);
result = `Map(${value.size}) { ${items.join(", ")} }`;
}
} else if (value instanceof Set) {
if (value.size === 0) {
result = "Set(0) {}";
} else {
const items = [];
let i = 0;
value.forEach((v) => {
if (i++ < MAX_ITEMS) items.push(_inspect(v, depth - 1, seen));
});
_truncated(items, value.size);
result = `Set(${value.size}) { ${items.join(", ")} }`;
}
} else if (ArrayBuffer.isView(value) && typeof value.length === "number") {
const name = value.constructor.name;
if (value.length === 0) {
result = `${name}([])`;
} else {
const items = _truncated(
Array.from(value.subarray(0, MAX_ITEMS), (v) =>
_inspect(v, depth - 1, seen),
),
value.length,
);
result = `${name}([ ${items.join(", ")} ])`;
}
} else {
const keys = Object.keys(value);
if (keys.length === 0) {
result = "{}";
} else {
const items = _truncated(
keys
.slice(0, MAX_ITEMS)
.map((k) => `${k}: ${_inspect(value[k], depth - 1, seen)}`),
keys.length,
);
result = `{ ${items.join(", ")} }`;
}
}
seen.delete(value);
return result;
}

function interpolate(template, params) {
return template.replace(/\{(\w+)\}/g, (match, key) =>
key in params ? params[key] : match,
key in params
? typeof params[key] === "string"
? params[key]
: inspect(params[key])
: match,
);
}

Expand Down
35 changes: 35 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,38 @@ expectError(

expectType<"NOT_FOUND">(NotFound.code);
expectType<"NotFound">(NotFound.name);

// ──────────────────────────────────────────────
// Non-string template parameters
// ──────────────────────────────────────────────

const NumParam = createErrorClass({
code: "NUM_ERR",
message: "Found {count} items",
status: 500,
});

// Number params accepted
new NumParam({ count: 42 });
// Object params accepted
new NumParam({ count: { nested: true } });
// Boolean params accepted
new NumParam({ count: true });
// null/undefined accepted
new NumParam({ count: null });
new NumParam({ count: undefined });

// Mixed types in multi-param template
const MixedParam = createErrorClass({
code: "MIXED_ERR",
message: "{name} has {count} items",
status: 500,
});
new MixedParam({ name: "Alice", count: 42 });
new MixedParam({ name: "Alice", count: [1, 2, 3] });

// String params still accepted (backward compatible)
new MixedParam({ name: "Alice", count: "many" });

// Custom message with non-string params
new NumParam("custom {val}", { val: 99 });
Loading