Repository navigation
WIP: tighten type, validation, and causality contracts (audit response) #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
martyy-code
merged 35 commits into
main
from
chore/fix-type-validation-causality-contracts
Oct 9, 2026
Merged
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 4dfb94b
fix(types): enable typecheck on tests, fix erroneous assertions
claude eb8643a
chore: add changeset for type-validation phase 1
claude 9c2e8bb
fix(runtime): decouple validation from message, freeze factory metada…
claude 3b2f14d
fix(causes): single source of truth for cause, V8 stack capture
claude 0bcf545
ci: wire type-check:test and test:consumer for @deessejs/errors
claude 1265653
fix(types): correct is() factories extraction, schema overload, stack…
claude 6adf14a
fix(contracts): snapshot parents, reject schema+string, fix lint
claude bcffd92
style: prettier --write on package sources
claude 3efc33a
fix(runtime): require input arg when factory carries a schema
claude 4056e68
fix: close PR #99 review gaps (required input, parent-schema validati…
martyy-code c753336
fix(error): validate every reachable ancestor at instantiation
martyy-code 40a5b3b
fix(error): freeze caller's inherits array at construction
martyy-code e923ff7
docs(changeset): describe transitive validation, transformations, and…
martyy-code a732928
test(transitive): adapt call sites to the static type contract
martyy-code ea89c25
fix(error): reject parent transformations that violate the cascade co…
martyy-code 12b8f85
docs(changeset): describe the cascade-compatibility contract
martyy-code 2a48056
fix(error): re-validate cascade against leaf schema; close deep-shape…
martyy-code d7f4242
refactor(errors): drop the parent-transformation cascade; pin contrac…
martyy-code 51948a9
fix(error): harden the static inheritance contract; cover is() narrowing
martyy-code beacc85
test(error): use @ts-ignore for non-erroring constraint sites; fix im…
martyy-code 296181d
test(error): align narrowing test with the R6 is() contract
martyy-code e44d431
style(errors): prettier reformat of R6 files
martyy-code 9ea5bbc
fix(error): enforce inheritance contract via acceptsFields witness; r…
martyy-code 5af6af1
fix(error): R8 audit hardening — is() spoofing, schema-output runtime…
martyy-code b113028
fix(error): R9 audit — path rendering, instance registry, shared inhe…
martyy-code e4c6d26
fix(error): R10 — real type-level reject of malformed schema outputs
martyy-code e469c5f
docs(errors): align public examples with the 2.0.0 contract; add docs…
martyy-code 809772a
ci: scope docs-examples-typecheck GITHUB_TOKEN to read-only
martyy-code 3ec3901
docs(errors): fix remaining broken MDX examples — manual-generic, ins…
martyy-code 0edcf3f
style: prettier --write on packages/errors/README.md
martyy-code 5e72c67
docs(errors): rewrite the 2.0.0 changeset; probe verifies // -> annot…
martyy-code ffb9054
docs(errors): consolidate to a single 2.0.0 changeset; retire obsolet…
martyy-code b91f32f
docs(errors): delete fix-type-validation-phase-1 changeset; fix .fiel…
martyy-code 895e226
Potential fix for pull request finding 'CodeQL / Workflow does not co…
martyy-code File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.