Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d51b5d6
WIP: types(schema): add InferStandardSchemaInput, factory I/O generics
claude Oct 8, 2026
4dfb94b
fix(types): enable typecheck on tests, fix erroneous assertions
claude Oct 8, 2026
eb8643a
chore: add changeset for type-validation phase 1
claude Oct 8, 2026
9c2e8bb
fix(runtime): decouple validation from message, freeze factory metada…
claude Oct 8, 2026
3b2f14d
fix(causes): single source of truth for cause, V8 stack capture
claude Oct 8, 2026
0bcf545
ci: wire type-check:test and test:consumer for @deessejs/errors
claude Oct 8, 2026
1265653
fix(types): correct is() factories extraction, schema overload, stack…
claude Oct 8, 2026
6adf14a
fix(contracts): snapshot parents, reject schema+string, fix lint
claude Oct 8, 2026
bcffd92
style: prettier --write on package sources
claude Oct 8, 2026
3efc33a
fix(runtime): require input arg when factory carries a schema
claude Oct 8, 2026
4056e68
fix: close PR #99 review gaps (required input, parent-schema validati…
martyy-code Oct 8, 2026
c753336
fix(error): validate every reachable ancestor at instantiation
martyy-code Oct 8, 2026
40a5b3b
fix(error): freeze caller's inherits array at construction
martyy-code Oct 8, 2026
e923ff7
docs(changeset): describe transitive validation, transformations, and…
martyy-code Oct 8, 2026
a732928
test(transitive): adapt call sites to the static type contract
martyy-code Oct 8, 2026
ea89c25
fix(error): reject parent transformations that violate the cascade co…
martyy-code Oct 8, 2026
12b8f85
docs(changeset): describe the cascade-compatibility contract
martyy-code Oct 8, 2026
2a48056
fix(error): re-validate cascade against leaf schema; close deep-shape…
martyy-code Oct 9, 2026
d7f4242
refactor(errors): drop the parent-transformation cascade; pin contrac…
martyy-code Oct 9, 2026
51948a9
fix(error): harden the static inheritance contract; cover is() narrowing
martyy-code Oct 9, 2026
beacc85
test(error): use @ts-ignore for non-erroring constraint sites; fix im…
martyy-code Oct 9, 2026
296181d
test(error): align narrowing test with the R6 is() contract
martyy-code Oct 9, 2026
e44d431
style(errors): prettier reformat of R6 files
martyy-code Oct 9, 2026
9ea5bbc
fix(error): enforce inheritance contract via acceptsFields witness; r…
martyy-code Oct 9, 2026
5af6af1
fix(error): R8 audit hardening — is() spoofing, schema-output runtime…
martyy-code Oct 9, 2026
b113028
fix(error): R9 audit — path rendering, instance registry, shared inhe…
martyy-code Oct 9, 2026
e4c6d26
fix(error): R10 — real type-level reject of malformed schema outputs
martyy-code Oct 9, 2026
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
160 changes: 160 additions & 0 deletions .changeset/fix-type-validation-phase-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
"@deessejs/errors": major
---

chore: tighten type, validation, and runtime contracts (audit Phases 1–6)

Addresses the type-side and runtime-side findings of the self-audit.

**Type contracts (Phases 1, 2, 6)**

- Activates typecheck on the test files. A new `tsconfig.test.json`
extends the base config and includes `src` and `tests`. A new
`type-check:test` npm script runs it.
- Separates `ErrorFactory` into `<TInput, TOutput>` so the input
contract (what the caller passes) and the output contract (what
`.fields` carries after validation) can differ when a schema
transforms. Adds `InferStandardSchemaInput<S>` alongside the
existing `InferStandardSchemaOutput<S>`.
- Adds `AnyErrorFactory = ErrorFactory<any, any>` for variance-
tolerant lists. The `inherits` field, the `is()` discriminator,
and the config unions all switch to this alias. Concrete
factories with different `T`/`O` generics now compose
correctly.
- Splits `is()` into two overloads. The factory overload returns
`ErrorInstance<ExtractFactoryFields<T>>`; the native-constructor
overload returns `Error`. The previous single signature falsely
promised `.fields`, `.notes`, `.from()`, and `.addNote()` on
native error instances.

**Validation (Phase 3)**

- The standard-schema branch now runs whenever a schema is supplied,
regardless of whether the message is a function or a string.
Before this change, a config like `{ fields: schema, message: 'literal' }`
silently skipped validation. The factory name is used as the
fallback error message; a function message still wins when present.
- `ArgsValidationError.source` now contains the factory's `name`
instead of the long-form 'Async schemas are not supported…' text.
The explanatory detail moves to `ArgsValidationError.issues`.
- The async-rejection path now attaches a no-op `.catch` to the
validator's pending Promise so a late rejection cannot surface
as an unhandledRejection.

**Runtime stability (Phase 4)**

- The factory object is now `Object.freeze`d at construction. The
`name`, `inherits`, and `schema` fields cannot be reassigned
after `error()` returns. This is the runtime enforcement for the
audit's observation that `is(child, Parent)` and `is(child, Other)`
must not flip just because someone mutated `Child.inherits`.

**Consumer smoke tests (Phase 5)**

- New `tests/consumer-from-dist.mjs` imports the published entry
point (`dist/index.js`) and exercises the public API surface
(error, raise, is, causes, ArgsValidationError). Run via
`pnpm test:consumer` (which builds first). Catches regressions
where source changes were not reflected in the build output.
- New `tests/inherits-immutable.test.ts` proves the Phase 4
guarantee: late mutation of `factory.inherits` does not flip
the classification of instances created before the mutation.
- New `tests/raise-typed.test.ts` pins the public contract of
`raise()` and the `is()` discrimination.

**Cause semantics (Phase 4b) — breaking**

- The flat `causes: Error[]` field on `ErrorInstance` has been
removed. The previous field conflated historical `.from()` calls
with a true causal chain.
- `cause: Error | null` is now the only direct field. Each
`.from()` call replaces the previous cause; the chain of
previous causes is reachable through their own `.cause` links.
- `causes(error)` now walks the chain by following each cause's
own `.cause` link, with cycle detection. The returned array
is a new copy on each call.

**Validation (Phase 3) — breaking**

- A Standard Schema without a function-form `message` is no
longer accepted by the public signature. The schema overload
requires `message: (data) => string`. Consumers that relied
on `{ fields: schema }` without `message` (where the legacy
string-template form was used) must add a function message
or a string message without a schema.

**Factory call signature — breaking**

- The previous `error<T>()` factory accepted `Partial<T>` so any
field could be omitted at the call site. The new signature
accepts `T` (or `TInput` when a schema is supplied). Callers
that relied on `Partial<T>` must now either supply the full
shape or annotate the field as optional in the schema.

**Required input argument — breaking**

- `ErrorFactory<TInput, TOutput>` now requires the input argument
at the call site whenever `TInput` is not the empty shape. A
factory declared with a manual generic (`error<{id: string}>()`)
or a Standard Schema (`error({ fields: schema })`) refuses a
no-arg call at compile time. The runtime also throws a localized
`TypeError` for the schema-bearing path so consumers who bypass
the type-checker still get a clear message. Legacy call patterns
(`error({ name })`, `error({ name, message: 'literal' })`) keep
the optional argument because their default `TInput` is the empty
shape.

**Inheritance guarantees structure — breaking**

- A factory declared with `inherits: Parent` is now required to
produce a `fields` shape that satisfies every reachable ancestor
that carries a schema. At instantiation, the child's fields are
re-validated against each ancestor's schema in a depth-first walk
rooted at the current factory. The walk reads from the frozen
`factory.inherits` snapshot (the same source of truth that `is()`
reads from), uses a `Set<AnyErrorFactory>` cycle guard shared
across siblings, and applies each ancestor's transformed output
to `data` as it cascades. Failure throws `ArgsValidationError`
with `source: <ancestor.name>`; the leaf's `instance.fields`
reflects every parent's transformation in the same order the
parents appear in `inherits`.
- `is(child, Parent)` and `is(child, Child)` are now honest at the
type level — the narrowed fields type is the intersection of the
factory's own output and all reachable ancestors' outputs — and
the runtime validation block enforces the same contract: a
factory whose `fields` do not satisfy an ancestor's schema
cannot produce a classify-able instance.
- The caller's `inherits` array is now `Object.freeze`d in place
at construction time (in addition to the Phase 4 freeze of the
factory's internal copy). Any in-place mutation of the array
after construction throws `TypeError` in strict mode. The
earlier implementation only froze the snapshot, leaving a
window where mutating the caller's array between factory
construction and the first invocation could desynchronize
the validation block (which read the closure) from `is()`
(which read the snapshot).

**Function-form `message` without a schema**

- The legacy branch now invokes the function with the validated
(or empty) fields and assigns its return to `errorMessage`.
Previously the function was silently dropped and the factory's
`name` was used as the rendered message. The audit's P2 finding
is closed; `error<{name: string}>({ name, message: d => d.name })`
now produces instances whose `.message` matches the function
output.

**V8 stack capture (Phase 11)**

- `Error.captureStackTrace(target, exclude)` is now used on V8
engines. The factory passes the closure as the exclude
argument, so the captured trace contains only the call site
of the factory invocation, not the factory's own frames.
Non-V8 engines fall back to the previous string-based filter.

The schema-driven input-shape inference (Phase 2's second half)
remains to be addressed in a follow-up PR. The current public
signature requires a function-form `message` when a schema is
supplied; the type tests carry `@ts-expect-error` markers
pointing at the missing input inference.

75 changes: 74 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ jobs:
# directory names contain a dot (e.g. `llms.txt/route.ts`). The
# production tsconfig does not enable it, so we run type-check:test
# explicitly. The regular `pnpm turbo type-check` is run on the
# rest of the workspace by the `Type Check` job below.
# rest of the workspace by the `Type Check` job below. The
# `Type Check (Tests)` job runs the same for @deessejs/errors.
- name: Type-check apps/web test suite
run: pnpm --filter web type-check:test

Expand Down Expand Up @@ -167,6 +168,78 @@ jobs:
- name: Run type check
run: pnpm turbo type-check

# ============================================================================
# Type Check (Tests) — exercises the test files in @deessejs/errors
# ============================================================================
type-check-tests:
name: Type Check (Tests)
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'

- name: Cache Turborepo
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}

- name: Install dependencies
run: pnpm install

# The standard `pnpm turbo type-check` runs `tsc --noEmit` against
# the source tsconfig which includes only `src/`. The
# `tsconfig.test.json` extends the base and includes the test
# files; this job catches regressions in the type-level tests
# that the source typecheck would silently miss.
- name: Run type check on tests
run: pnpm --filter @deessejs/errors type-check:test

# ============================================================================
# Consumer from dist — proves the published entry point is consumable
# ============================================================================
consumer-from-dist:
Comment on lines +175 to +211
name: Consumer from dist
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'

- name: Cache Turborepo
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}

- name: Install dependencies
run: pnpm install

# The script builds first (tsc -p tsconfig.build.json) and
# then runs tests/consumer-from-dist.mjs which imports
# dist/index.js and exercises the public API.
- name: Build and run consumer smoke test
run: pnpm --filter @deessejs/errors test:consumer

# ============================================================================
# Changeset required (PRs to staging only)
# ============================================================================
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/error-factory.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ console.log(AppError.name); // "AppError"
console.log(AppError.inherits); // undefined (no parent)
```

The `inherits` property exposes the parent factory or array of factories, which `is()` consults when checking inheritance. The schema is reachable via `AppError.fields` when supplied.
The `inherits` property exposes the parent factory or array of factories, which `is()` consults when checking inheritance. The instance carries the validated `fields` shape and the parent relationships for introspection.

## Reusing Error Factories

Expand Down
17 changes: 12 additions & 5 deletions apps/web/content/docs/error-instance.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ const appErr = AppError({});
appErr.from(validationErr);

console.log(appErr.cause === validationErr); // true
console.log(appErr.causes.length); // 1
// The full chain is reachable via the causes() helper, which
// walks the cause links and returns the chain immediate-cause first.
import { causes } from '@deessejs/errors';
console.log(causes(appErr).length); // 1
```

The `from()` method returns the error instance, allowing you to chain multiple calls or combine it with other operations.
Expand All @@ -84,19 +87,23 @@ When no cause has been set, `cause` is `null`.
The `causes` array contains the entire chain of errors, ordered from most recent to oldest. This gives you the full history of what happened.

```ts title="causes-array.ts"
import { causes, error } from '@deessejs/errors';

const AppError = error({ name: 'AppError' });
const ValidationError = error({ name: 'ValidationError' });

const appErr = AppError({});
appErr.from(ValidationError({ field: 'email' }));
appErr.from(new Error('Database connection failed'));

console.log(appErr.causes.length); // 2
console.log(appErr.causes[0].name); // "ValidationError" (most recent)
console.log(appErr.causes[1].message); // "Database connection failed" (oldest)
// Walk the chain via causes() — immediate cause first, root cause last.
const chain = causes(appErr);
console.log(chain.length); // 2
console.log(chain[0].name); // "ValidationError" (most recent)
console.log(chain[1].message); // "Database connection failed" (oldest)
```

When you chain multiple errors, the new cause is added to the front of the array, keeping chronological order.
When you chain multiple errors, the new cause replaces the direct cause; the previous cause becomes the second link in the chain, keeping causal order.

## The notes Property

Expand Down
33 changes: 20 additions & 13 deletions apps/web/content/docs/from-method.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,31 @@ The `from()` method returns the error instance, so you can chain method calls or

## Multiple Causes

A single error can have multiple causes in its chain. Each call to `from()` adds a new cause to the front of the chain:
A single error has one **direct cause** at a time: each `from()` call replaces the previous cause with a new one. To build a chain, the previous cause itself carries its own `.cause` — walk the chain with the `causes()` helper, which returns the immediate cause down to the root.

```ts title="multiple.ts"
import { error, causes } from '@deessejs/errors';

const AppError = error({ name: 'AppError' });
const ValidationError = error({ name: 'ValidationError' });

const appErr = AppError({});

// Add causes in order
// Build the chain: each .from() sets the direct cause.
appErr.from(ValidationError({ field: 'email' }));
appErr.from(new Error('Database connection failed'));

// Most recent cause is first
console.log(appErr.causes.length); // 2
console.log(appErr.causes[0].name); // "ValidationError"
console.log(appErr.causes[1].message); // "Database connection failed"
// The direct cause is the last .from() call.
console.log(appErr.cause?.message); // "Database connection failed"

// Walk the chain — the previous cause is reachable transitively.
const chain = causes(appErr);
console.log(chain.length); // 2 — [ValidationError, Error]
console.log(chain[0].name); // "ValidationError"
console.log(chain[1].message); // "Database connection failed"
```

The order is always newest first, which makes sense when reading error chains — the most recent cause is what led directly to the current error.
The chain order is always immediate-cause first, root-cause last. The `causes()` helper detects cycles.

## Chaining with raise()

Expand Down Expand Up @@ -87,7 +93,7 @@ try {

## Accessing the Full Chain

After building an error chain, you can access all causes through the `causes` array or use the `causes()` helper function for more robust access:
The `causes()` helper walks the chain by following each cause's own `.cause` link. It detects cycles and returns a new array on each call (mutations do not affect the underlying error).

```ts title="access.ts"
import { error, causes } from '@deessejs/errors';
Expand All @@ -99,14 +105,15 @@ const appErr = AppError({});
appErr.from(ValidationError({ field: 'email' }));
appErr.from(new Error('Connection timeout'));

// Using the causes array directly
console.log(appErr.causes.length); // 2

// Using the causes() helper (handles non-@deessejs/errors errors)
// Walk the chain via the causes() helper.
const chain = causes(appErr);
console.log(chain.length); // 2
console.log(chain.length); // 2 — [ValidationError, Error]
console.log(chain[0].name); // "ValidationError"
console.log(chain[1].message); // "Connection timeout"
```

`causes()` accepts any value: a factory-produced `ErrorInstance`, a native `Error`, or `null`/`undefined` (returns `[]`). It also walks native `Error.cause` chains, so mixed native and factory errors compose.

The `causes()` function is particularly useful when working with errors from different sources, as it gracefully handles cases where the `causes` array might not exist.

## Why Chain Errors?
Expand Down
5 changes: 4 additions & 1 deletion apps/web/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,17 @@ try {
When an error occurs as a result of another error, you can preserve that relationship using the `.from()` method. This creates a chain that maintains the full history of what went wrong.

```ts title="chaining.ts"
import { causes, error } from '@deessejs/errors';

const validationErr = ValidationError({});
const appErr = error({ name: 'AppError' })();

// Chain the validation error as the cause
appErr.from(validationErr);

console.log(appErr.cause === validationErr); // true
console.log(appErr.causes.length); // 1
// The full chain is reachable via the causes() helper.
console.log(causes(appErr).length); // 1
```

### Hierarchical Inheritance
Expand Down
Loading
Loading