Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-74-causes-structural-guard.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 22 additions & 12 deletions packages/errors/src/causes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
* }
* ```
*
Expand All @@ -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 };
10 changes: 10 additions & 0 deletions packages/errors/tests/causes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading