From 0a5d6c0188e1c263b60619e1a0f6f77338ac658c Mon Sep 17 00:00:00 2001 From: martyy-code Date: Wed, 12 Aug 2026 11:39:45 +0200 Subject: [PATCH] fix(errors): replace cast in causes/index.ts with a structural guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #74: `causes()` cast its `unknown` input via `error as ErrorInstance` to read `instance.causes`. The cast was the only operation that touched the type system in the function. Combined with the redundant null check at the top, the function trusted neither its type nor its narrowing. Replace the cast with a structural guard: `'causes' in error && Array.isArray(error.causes)`. The `unknown` input is no longer smuggled through an `as` to satisfy a compiler that was happy to be satisfied; the input must now have a shape the function can defend by name. The `typeof error !== 'object'` branch covers primitives (`string`, `number`, `boolean`) which would otherwise fall through the `'causes' in` check and crash. The first example in the JSDoc was a broken template literal (```typescript try { // ... } catch { ... }```) — repaired to a complete try/catch example. A regression test in `causes.test.ts` covers the path the cast used to swallow silently: an object with `causes: 'not an array'`, `causes: null`, or an array-like non-`Array` value. All return `[]`. Public API unchanged; 83/83 tests pass. Closes #74. --- .changeset/fix-74-causes-structural-guard.md | 5 +++ packages/errors/src/causes/index.ts | 34 +++++++++++++------- packages/errors/tests/causes.test.ts | 10 ++++++ 3 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-74-causes-structural-guard.md diff --git a/.changeset/fix-74-causes-structural-guard.md b/.changeset/fix-74-causes-structural-guard.md new file mode 100644 index 0000000..92e634b --- /dev/null +++ b/.changeset/fix-74-causes-structural-guard.md @@ -0,0 +1,5 @@ +--- +"@deessejs/errors": patch +--- + +Replace the cast in `causes/index.ts` (`error as ErrorInstance`) with a structural guard (`'causes' in error && Array.isArray(error.causes)`). The function no longer imports `ErrorInstance` and the input is honest about its `unknown` shape. The first example in the JSDoc was broken (a template literal cut mid-sentence); it is now a complete try/catch example. A regression test verifies that an object carrying `causes: 'not an array'`, `causes: null`, or an array-like (non-`Array`) value returns `[]`. Public API unchanged; 83 tests pass (82 → 83). Closes #74. \ No newline at end of file diff --git a/packages/errors/src/causes/index.ts b/packages/errors/src/causes/index.ts index a2c51fd..b9d933f 100644 --- a/packages/errors/src/causes/index.ts +++ b/packages/errors/src/causes/index.ts @@ -2,18 +2,27 @@ * Cause chain traversal utilities. */ -import type { ErrorInstance } from '../error/types.js'; - /** * Returns all causes in the error chain, from most recent to root cause. * - * @param error - The error to get causes from - * @returns Array of errors in the cause chain, ordered newest to oldest + * The function uses a structural guard (`'causes' in error && Array.isArray(error.causes)`) + * rather than a type cast. This is rule 0004 in operational form: the + * guard is named, the scenario it covers is named, and the input can be + * `unknown` without an `as ErrorInstance` cast at the call site. + * + * @param error - The error to get causes from (any value; `null` and + * `undefined` return `[]`) + * @returns Array of errors in the cause chain, ordered newest to + * oldest. Returns `[]` when the input does not carry a `causes` array. * * @example - * ```typescript try { - * // ... } catch (err) { const chain = causes(err); - * chain.forEach(e => logError(e)); + * ```typescript + * import { causes, raise } from '@deessejs/errors'; + * + * try { + * await sync(); + * } catch (err) { + * causes(err).forEach((cause) => logError(cause)); * } * ``` * @@ -32,14 +41,15 @@ const causes = (error: unknown): Error[] => { return []; } - // Get the causes array from the error - const instance = error as ErrorInstance; + if (typeof error !== 'object') { + return []; + } - if (Array.isArray(instance.causes)) { - return instance.causes; + if (!('causes' in error) || !Array.isArray(error.causes)) { + return []; } - return []; + return error.causes; }; export { causes }; diff --git a/packages/errors/tests/causes.test.ts b/packages/errors/tests/causes.test.ts index 87f5f06..9ebcbc1 100644 --- a/packages/errors/tests/causes.test.ts +++ b/packages/errors/tests/causes.test.ts @@ -114,6 +114,16 @@ describe('causes() function', () => { expect(causes({})).toEqual([]); }); + it('should return empty array when causes property is not an array', () => { + // Regression for issue #74: a non-array `causes` property + // (e.g. from a malformed foreign value) must not be returned + // as-is. The structural guard rejects the shape before any + // cast reaches the consumer. + expect(causes({ causes: 'not an array' })).toEqual([]); + expect(causes({ causes: null })).toEqual([]); + expect(causes({ causes: { length: 1, 0: 'fake' } })).toEqual([]); + }); + it('should return causes property directly', () => { const AppError = error({ name: 'AppError' }); const cause = AppError();