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();