From 8a00bdb38bac0a344a57a08d87a8172b57f3fdc5 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 3 Mar 2026 10:15:20 +0000 Subject: [PATCH 1/2] allow ErrorOpts as first arg for no-param errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For errors without template params, `new Err({ cause })` is now a valid shorthand for `new Err(undefined, { cause })`. Parameterized errors are unaffected — their first object arg is still treated as params. Co-Authored-By: Claude Opus 4.6 --- index.d.ts | 1 + index.js | 13 +++++++--- index.test-d.ts | 27 ++++++++++++++++++++ test/index.test.js | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/index.d.ts b/index.d.ts index c241241..8732dfe 100644 --- a/index.d.ts +++ b/index.d.ts @@ -64,6 +64,7 @@ export type ErrorConstructor = (HasParams< } : { new (): ErrorInstance; + new (opts: ErrorOpts): ErrorInstance; new (message: string): ErrorInstance; new (message: string | undefined, opts: ErrorOpts): ErrorInstance; }) & { diff --git a/index.js b/index.js index 658b93e..aa05b19 100644 --- a/index.js +++ b/index.js @@ -31,6 +31,8 @@ export function createErrorClass(definition) { validateMessage(code, defaultMessage); + const hasTemplateParams = /\{\w+\}/.test(defaultMessage); + const ErrorKlass = class extends Error { code = code; status = status; @@ -43,9 +45,14 @@ export function createErrorClass(definition) { let params, cause; if (typeof messageOrParams === "object" && messageOrParams !== null) { - // First arg is params object: new Err(params) or new Err(params, opts) - params = messageOrParams; - cause = paramsOrOpts?.cause; + if (!hasTemplateParams && "cause" in messageOrParams) { + // No-param error: first arg is ErrorOpts + cause = messageOrParams.cause; + } else { + // First arg is params object: new Err(params) or new Err(params, opts) + params = messageOrParams; + cause = paramsOrOpts?.cause; + } } else if (typeof paramsOrOpts === "object" && paramsOrOpts !== null) { if (opts !== undefined) { // Three-arg form: new Err("msg", params, opts) diff --git a/index.test-d.ts b/index.test-d.ts index d09bbef..e8a32a0 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -239,3 +239,30 @@ expectError( expectType<"NOT_FOUND">(NotFound.code); expectType<"NotFound">(NotFound.name); + +// ────────────────────────────────────────────── +// ErrorOpts as first parameter (no-param errors) +// ────────────────────────────────────────────── + +const SimpleErr = createErrorClass({ + code: "SIMPLE", + message: "Something failed", + status: 500, +}); + +// ErrorOpts as first arg — should compile for no-param errors +new SimpleErr({ cause: new Error("root") }); +new SimpleErr({ cause: "string cause" }); +new SimpleErr({ cause: null }); +new SimpleErr({ cause: 42 }); + +// Empty object also valid (cause is optional in ErrorOpts) +new SimpleErr({}); + +// Should NOT compile for parameterized errors (object treated as params, wrong shape) +const ParamErrOpts = createErrorClass({ + code: "PARAM_ERR", + message: "Missing {field}", + status: 400, +}); +expectError(new ParamErrOpts({ cause: new Error() })); diff --git a/test/index.test.js b/test/index.test.js index 62d22c8..c02884f 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -318,6 +318,69 @@ describe("createErrorClass", () => { }); }); + describe("ErrorOpts as first argument for no-param errors", () => { + const Err = createErrorClass({ + code: "SIMPLE", + message: "Something failed", + status: 500, + }); + + it("accepts { cause } as first argument", () => { + const cause = new Error("root cause"); + const err = new Err({ cause }); + assert.equal(err.message, "Something failed"); + assert.equal(err.cause, cause); + }); + + it("accepts { cause: null } preserving falsy cause", () => { + const err = new Err({ cause: null }); + assert.equal(err.message, "Something failed"); + assert.equal(err.cause, null); + }); + + it("accepts { cause: undefined } — no cause set", () => { + const err = new Err({ cause: undefined }); + assert.equal(err.message, "Something failed"); + assert.equal(err.cause, undefined); + }); + + it("accepts {} — no cause set, uses default message", () => { + const err = new Err({}); + assert.equal(err.message, "Something failed"); + assert.equal(err.cause, undefined); + }); + + it("does not affect parameterized errors", () => { + const ParamErr = createErrorClass({ + code: "PARAM_ERR", + message: "Missing {field}", + status: 400, + }); + // For parameterized errors, first object arg is always params + const err = new ParamErr({ field: "name" }); + assert.equal(err.message, "Missing name"); + assert.equal(err.cause, undefined); + }); + + it("still supports string message for no-param errors", () => { + const err = new Err("Custom message"); + assert.equal(err.message, "Custom message"); + }); + + it("still supports string message with cause in second arg", () => { + const cause = new Error("root"); + const err = new Err("Custom", { cause }); + assert.equal(err.message, "Custom"); + assert.equal(err.cause, cause); + }); + + it("cause via opts is non-enumerable", () => { + const err = new Err({ cause: new Error("root") }); + const descriptor = Object.getOwnPropertyDescriptor(err, "cause"); + assert.equal(descriptor.enumerable, false); + }); + }); + describe("reserved parameter validation", () => { it("throws at definition time when message uses {cause}", () => { assert.throws( From fcb9578dfa0b29ed77e9db9323ce6f9a830dba6d Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 3 Mar 2026 15:27:51 +0000 Subject: [PATCH 2/2] remove duplicate tests from ErrorOpts describe block Co-Authored-By: Claude Opus 4.6 --- test/index.test.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/test/index.test.js b/test/index.test.js index c02884f..cc6cbf7 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -362,18 +362,6 @@ describe("createErrorClass", () => { assert.equal(err.cause, undefined); }); - it("still supports string message for no-param errors", () => { - const err = new Err("Custom message"); - assert.equal(err.message, "Custom message"); - }); - - it("still supports string message with cause in second arg", () => { - const cause = new Error("root"); - const err = new Err("Custom", { cause }); - assert.equal(err.message, "Custom"); - assert.equal(err.cause, cause); - }); - it("cause via opts is non-enumerable", () => { const err = new Err({ cause: new Error("root") }); const descriptor = Object.getOwnPropertyDescriptor(err, "cause");