diff --git a/CHANGELOG.md b/CHANGELOG.md index 66ee5a9e..3bb6b7a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Features +- feat(errors): expose `isOwnInstance` to `createCustomError`'s `constructCb` and skip redundant stack captures + - `constructCb` now receives a 3rd argument, `isOwnInstance`, which is `true` when the class is the leaf-most type actually being instantiated and `false` when its constructor is only running as a base-class step for a subclass further down an inheritance chain + - The internal `Error.captureStackTrace` call is now gated by the same check, so it only fires once per instance (at the leaf-most class) instead of redundantly at every level of a chain + - Lets custom error hierarchies with their own per-level stack-capture logic skip that work except at the leaf + ### Bug Fixes # v0.15.0 May 29th, 2026 diff --git a/lib/src/helpers/customError.ts b/lib/src/helpers/customError.ts index b9ef350f..d5fd43e5 100644 --- a/lib/src/helpers/customError.ts +++ b/lib/src/helpers/customError.ts @@ -53,7 +53,13 @@ function _setName(baseClass: any, name: string) { * @group Error * @param name - The name of the Custom Error * @param constructCb - [Optional] An optional callback function to call when a - * new Custom Error instance is being created. + * new Custom Error instance is being created. The 3rd argument (since v0.16.0), `isOwnInstance`, is `true` + * when this class is the leaf-most (most-derived) type actually being instantiated (eg. `new ThisClass()`), + * and `false` when this class's own constructor is only running because it is a base class of some other + * custom error further down an inheritance chain (eg. `new SomeSubClass()`, where `SomeSubClass` was created + * via `createCustomError(..., ThisClass)`). Use this to skip work - such as your own stack-trace capture - + * that only the leaf-most instance actually needs, since a base class's own construction step runs (and + * would otherwise redo that work) for every instance of every one of its subclasses too. * @param errorBase - [Optional] (since v0.9.6) The error class to extend for this class, defaults to Error. * @param superArgsFn - [Optional] (since v0.13.0) An optional function that receives the constructor arguments and * returns the arguments to pass to the base class constructor. When not provided all constructor @@ -151,16 +157,20 @@ function _setName(baseClass: any, name: string) { /*#__NO_SIDE_EFFECTS__*/ export function createCustomError( name: string, - constructCb?: ((self: any, args: IArguments) => void) | null, + constructCb?: ((self: any, args: IArguments, isOwnInstance?: boolean) => void) | null, errorBase?: B, superArgsFn?: ((args: IArguments) => ArrayLike) | null): T { let theBaseClass = errorBase || Error; let orgName = theBaseClass[PROTOTYPE][NAME]; let captureFn = Error.captureStackTrace; - return _createCustomError(name, function (this: any) { + return _createCustomError(name, function _ctor(this: any) { let _this = this; let theArgs = arguments; + // this[CONSTRUCTOR] always resolves (via the prototype chain) to the leaf-most class + // actually being instantiated, so comparing it against ourselves tells us whether we're + // that leaf class or just being invoked as a base class for a child further down the chain + let isOwnInstance = _this[CONSTRUCTOR] === _ctor; try { safe(_setName, [theBaseClass, name]); let _self = fnApply(theBaseClass, _this, superArgsFn ? superArgsFn(theArgs) : ArrSlice[CALL](theArgs)) || _this; @@ -172,12 +182,14 @@ export function createCustomError(cb: () => never, message: string): T { try { @@ -380,5 +383,77 @@ describe("throw helpers", () => { assert.equal(err.message, "hello", "Message is set correctly"); }); }); + + describe("createCustomError internal stack capture optimization", () => { + it("captures the stack only once for a multi-level chain", () => { + if (!_hasCaptureStackTrace) { + return; + } + + let captureSpy = sinon.spy(Error as any, "captureStackTrace"); + try { + let Level1 = createCustomError("Level1CaptureTest"); + let Level2 = createCustomError("Level2CaptureTest", null, Level1); + let Level3 = createCustomError("Level3CaptureTest", null, Level2); + + captureSpy.resetHistory(); + let err = new (Level3 as any)("boom"); + assert.ok(isError(err), "isError returns true"); + assert.equal(captureSpy.callCount, 1, "Expected exactly one automatic capture, for the leaf-most class"); + } finally { + captureSpy.restore(); + } + }); + }); + + describe("createCustomError constructCb isOwnInstance argument", () => { + it("is true when the class is instantiated directly with no base chain", () => { + let seen: (boolean | undefined)[] = []; + let MyError = createCustomError("IsOwnInstanceDirectTest", (self, args, isOwnInstance) => { + seen.push(isOwnInstance); + }); + + new (MyError as any)("boom"); + assert.deepEqual(seen, [true], "constructCb should see isOwnInstance === true"); + }); + + it("is false for base classes and true only for the leaf-most class in a chain", () => { + let calls: { name: string; isOwnInstance: boolean | undefined }[] = []; + + let Level1 = createCustomError("IsOwnInstanceLevel1Test", (self, args, isOwnInstance) => { + calls.push({ name: "Level1", isOwnInstance }); + }); + let Level2 = createCustomError("IsOwnInstanceLevel2Test", (self, args, isOwnInstance) => { + calls.push({ name: "Level2", isOwnInstance }); + }, Level1); + let Level3 = createCustomError("IsOwnInstanceLevel3Test", (self, args, isOwnInstance) => { + calls.push({ name: "Level3", isOwnInstance }); + }, Level2); + + let err = new (Level3 as any)("boom"); + assert.ok(isError(err), "isError returns true"); + + assert.equal(calls.length, 3, "Every level's constructCb should still run"); + assert.equal(calls[0].name, "Level1", "Level1's own constructCb runs first (innermost base call)"); + assert.equal(calls[0].isOwnInstance, false, "Level1 is only running as a base class here"); + assert.equal(calls[1].name, "Level2"); + assert.equal(calls[1].isOwnInstance, false, "Level2 is only running as a base class here"); + assert.equal(calls[2].name, "Level3"); + assert.equal(calls[2].isOwnInstance, true, "Level3 is the leaf-most class actually being instantiated"); + }); + + it("the same base class sees isOwnInstance true when instantiated directly and false when used as a base", () => { + let baseCalls: (boolean | undefined)[] = []; + let Base = createCustomError("IsOwnInstanceBaseTest", (self, args, isOwnInstance) => { + baseCalls.push(isOwnInstance); + }); + let Leaf = createCustomError("IsOwnInstanceLeafTest", null, Base); + + new (Base as any)("direct"); + new (Leaf as any)("via-subclass"); + + assert.deepEqual(baseCalls, [ true, false ], "Base sees true when it's the leaf, false when acting as a base class for Leaf"); + }); + }); });