Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
e469c5f
docs(errors): align public examples with the 2.0.0 contract; add docs…
martyy-code Oct 9, 2026
809772a
ci: scope docs-examples-typecheck GITHUB_TOKEN to read-only
martyy-code Oct 9, 2026
3ec3901
docs(errors): fix remaining broken MDX examples — manual-generic, ins…
martyy-code Oct 9, 2026
0edcf3f
style: prettier --write on packages/errors/README.md
martyy-code Oct 9, 2026
5e72c67
docs(errors): rewrite the 2.0.0 changeset; probe verifies // -> annot…
martyy-code Oct 9, 2026
ffb9054
docs(errors): consolidate to a single 2.0.0 changeset; retire obsolet…
martyy-code Oct 9, 2026
b91f32f
docs(errors): delete fix-type-validation-phase-1 changeset; fix .fiel…
martyy-code Oct 9, 2026
895e226
Potential fix for pull request finding 'CodeQL / Workflow does not co…
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
222 changes: 222 additions & 0 deletions .changeset/r5-r10-type-and-runtime-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
---
'@deessejs/errors': major
---

# 2.0.0

This release locks in the public surface of `@deessejs/errors`. The
two `error()` overloads are the supported entry points; the
constraints around `inherits`, `from`, `is`, and `causes` are
pinned in the type system and verified by a runtime probe against
every example in the docs.

## What shipped

### Two `error()` overloads

The function is overloaded on whether a Standard Schema is supplied
for `fields`. Both overloads share the same call-site shape for
`name`, `message`, and `inherits`.

```ts
// No-schema form. The factory is callable with no arguments,
// or with a manual generic that constrains the field shape.
//
// const X = error<{ n: string }>({ name: 'X' });
// X({ n: 'value' }).fields.n // string
//
// The no-schema form accepts a string `message` (with optional
// `{field}` placeholders) or a function `message: (data) => string`.
const E = error({ name: 'E' });
const instance = E();

// Schema form. The factory's call signature requires the
// schema's `InferInput` as the argument; `message` is a function
// of the schema's `InferOutput`. A string `message` is not
// accepted alongside a schema.
const S = error({
name: 'S',
fields: z.object({ n: z.number() }),
message: (data) => String(data.n),
});
const s = S({ n: 1 });
```

A factory with a schema always requires its input, even for
`z.object({})`. A no-schema factory is callable with no arguments
when `T` defaults to `Record<string, never>`, or with the input
required by the manual generic.

### Inheritance is structural

`inherits` is a static type-level relationship plus a recognition
walk for `is()`. The leaf's `InferOutput` (or manual generic `T`)
must be assignable to every parent's `InferOutput`; the
constraint is enforced at the `error()` definition site. The
walk is what makes `is()` match a child against any of its
ancestors.

```ts
const Parent = error({
name: 'Parent',
fields: z.object({ id: z.string() }),
message: (d) => d.id,
});
const Child = error({
name: 'Child',
fields: z.object({ id: z.string(), extra: z.string() }),
message: (d) => `${d.id}-${d.extra}`,
inherits: Parent,
}); // ok
```

`Parent.fields.id` is the only requirement the child must
satisfy. The child may add fields but must not drop or change
the parent's required ones. The library does not run the
parent's schema at instantiation; the leaf's own schema is
the only one that runs.

### `.from()` builds chains, one link at a time

Each `.from()` call sets the direct cause of the receiver. To
build a chain of length N, attach each layer's cause to the
previous one — the previous cause carries its own `.cause`.

```ts
const root = new Error('Network timeout');
const middle = ValidationError().from(root);
const app = AppError().from(middle);
const chain = causes(app); // [middle, root]
```

The chain is recoverable because every link carries its own
`.cause`; replacing the direct cause on a layer drops the
previous link from the walk. `causes()` returns a fresh array,
ordered immediate cause first, root cause last, with cycle
detection.

### `is()` narrows and walks

`is(err, factory)` returns `true` if `err` is an instance of
the factory, directly or through the inheritance walk. The
factory overload narrows `err` to that factory's own
`InferOutput`. The native-constructor overload narrows to
`InstanceType<typeof NativeErrorClass>`.

The walk consults a package-private `WeakSet` keyed on
instance identity, in addition to the factory marker. A
foreign object that imitates the marker slot is rejected by
the registry check. See the Caveats for the per-load limit.

## Breaking changes from 1.x

- **Required input argument.** A factory declared with a
Standard Schema or a non-empty manual generic refuses a
no-arg call at compile time. Legacy call patterns
(`error({ name })`, `error({ name, message: 'literal' })`)
keep the optional argument because their default `TInput`
is the empty shape.

- **Schema overload requires a function-form `message`.** A
string `message` is no longer accepted alongside a schema.
Consumers that relied on `{ fields: schema }` without a
function `message` must add one.

- **Factory call signature takes the full shape, not `Partial<T>`.**
The 1.x signature accepted `Partial<T>` so any field could
be omitted. The 2.0 signature accepts `T` (or `TInput` when
a schema is supplied). Callers must either supply the full
shape or annotate fields as optional in the schema.

- **The flat `causes: Error[]` field on `ErrorInstance` is gone.**
The previous field conflated historical `.from()` calls
with a true causal chain. `cause: Error | null` is now the
only direct field. Use the top-level `causes(error)`
function to walk the chain.

- **`is()` is split into two overloads.** The factory
overload narrows to `ErrorInstance<...>`; the
native-constructor overload narrows to `Error` and
does not promise `.fields`, `.notes`, `.from()`, or
`.addNote()`.

## Caveats

- **`strictFunctionTypes` is required.** The contravariance
witness that enforces inheritance compatibility relies on
function parameters being checked contravariantly. Without
`strictFunctionTypes`, the inheritance constraint is
bypassed. `strictFunctionTypes` is enabled by `strict`,
which is on in this project's tsconfig.

- **TypeScript 5.4 or later.** The public `error()` overloads
use `NoInfer<T>`, which was introduced in TypeScript 5.4.
Earlier versions compile but lose the `NoInfer` semantics on
the `T` parameter.

- **Schema output must be a non-null, non-array object.** A
schema whose `InferOutput` is `null`, an `unknown[]`, a
primitive, or `undefined` is rejected at compile time via
the `fields: S & (IsObjectOutput<InferOutput<S>> extends true
? unknown : never)` intersection, and at runtime via the
`isObjectFields` guard that throws `ArgsValidationError` if
the schema's value is malformed.

- **Schemas must be synchronous.** A schema whose `validate`
returns a `Promise` is rejected with `ArgsValidationError`.
The Promise is still attached and a no-op `.catch` is added
so a late rejection cannot surface as an `unhandledRejection`.

- **Input is required when a schema is supplied.** A factory
declared with a Standard 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. This applies even
when the schema accepts the empty shape (`z.object({})`).

- **`.fields` carries the post-transform output.** For schemas
that transform (e.g. `z.coerce`, `z.default`, `z.transform`),
the leaf's `.fields` is the schema's `InferOutput`, not the
`InferInput`. The factory's `message` function receives the
post-transform output.

- **`is()` is per-package-load.** An instance created by a
second copy of `@deessejs/errors` (a duplicate in
`node_modules`, a separate bundle, a CommonJS/ESM dual load,
two copies in a micro-frontend) is registered in *that*
copy's internal `INSTANCE_REGISTRY` (a `WeakSet`), not ours.
Holding a direct reference to its factory is not sufficient:
the marker slot is read and matches, but the registry check
fails, and `is()` returns false. To recognize cross-load
instances, the consumer must call `is()` from the same load
that produced the instance.

- **The instance marker is non-writable on real instances.**
Set via `Object.defineProperty` with `writable: false`. A
foreign object that imitates the marker is rejected by the
registry check; a hostile object that re-defines the marker
on a real instance is rejected in strict mode (the
assignment throws) and silently fails in sloppy mode (the
marker is unchanged).

- **The factory's `inherits` slot is a snapshot.** Built once
at construction, before the closure, and used for both the
factory's metadata and every produced instance's `inherits`.
The caller's array is not mutated. Classification is
preserved across caller-side mutations on the original
list.

## Docs

- **Public examples in `apps/web/content/docs/*.mdx` are
type-checked AND runtime-verified against `dist/index.d.ts`.**
`tests/docs-examples-typecheck.mjs` walks every fenced
TypeScript code block in the docs, runs `tsc --noEmit`
against the published types, then executes the block under
`node --experimental-strip-types` and verifies every
`console.log(...) // -> <expected>` annotation against the
captured stdout. A doc that announces the wrong value
fails the probe. As part of the audit, all MDX examples
were rewritten to compile, to assert the values the page
claims, and to build chains layer-by-layer so the
announced `causes(...).length` matches the walk.
114 changes: 113 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main, staging, dev]

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down Expand Up @@ -126,7 +129,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 +171,114 @@ 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 thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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

# ============================================================================
# Docs examples — public example blocks in apps/web/content/docs/*.mdx
# must compile against the published dist/index.d.ts. The probe
# walks every fenced TypeScript code block, runs tsc on each in
# isolation, and reports drift between the docs and the public API.
# ============================================================================
docs-examples-typecheck:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
name: Docs examples type-check
runs-on: ubuntu-latest
# Restrict the GITHUB_TOKEN to read-only; the job only
# checks out code and runs tsc, no write access needed.
permissions:
contents: read

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: Install dependencies
run: pnpm install

# Builds first (tsc -p tsconfig.build.json), then walks
# the MDX code blocks in apps/web/content/docs/ and runs
# tsc against the published dist/index.d.ts.
- name: Build and type-check public examples
run: pnpm --filter @deessejs/errors test:docs

# ============================================================================
# Changeset required (PRs to staging only)
# ============================================================================
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,9 @@ temp/
# Generated files
*.tsbuildinfo

# Docs examples type-check probe fixtures (generated on the
# fly by packages/errors/tests/docs-examples-typecheck.mjs).
packages/errors/tests/.docs-probe/

# Changesets
.changeset/
Loading
Loading