Repository navigation
WIP: tighten type, validation, and causality contracts (audit response) - #99
Draft
martyy-code wants to merge 17 commits into
Draft
martyy-code wants to merge 17 commits into
martyy-code wants to merge 17 commits into
Conversation
Partial progress on the audit recommended in the project (self-audit, see PR description for full context). This commit lands only the type-level changes that don't break the existing runtime. Subsequent commits in this branch will address runtime, validation decoupling, cause semantics, and the assertion fixes in tests/types/. Changes: - types.ts: InferStandardSchemaInput<S> in addition to InferStandardSchemaOutput<S>. Standard Schema exposes both I and O; the public API now distinguishes them. - types.ts: ErrorFactory<TInput, TOutput> replaces the single ErrorFactory<T> type parameter. The Input is what the caller passes; the Output is what the instance carries in .fields. They may differ for schemas that transform. - types.ts: ErrorInstance.causes is re-annotated as @deprecated. The flat list conflates historical .from() calls with a true causal chain. Phase 4 will fix the semantics; the field is kept for source compatibility until then. - raise/index.ts: raise<T> is now generic over the ErrorInstance's field type, so raise(E({ id: 'x' })) compiles regardless of T. Previously the default Record<string, never> rejected all non-empty ErrorInstance types. Not changed yet (intentional, deferred to subsequent commits): - error() signature: still T extends Record<string, unknown>. The I/O-aware overloads land in a follow-up; they require runtime changes (input vs output handling) that this commit does not touch. - is() type guards: native vs factory discrimination deferred. - Validation: still gated on message being a function. - causes(): semantics unchanged. - Tests in tests/types/error-type.test.ts: still express invariants the runtime doesn't yet hold. Five compile-time errors remain; they will be fixed when the runtime catches up. Validation (against current state of main): - pnpm type-check (src): clean - pnpm test:run: 146/146 pass - pnpm build: clean - pnpm lint: 0 errors
Addresses audit Phase 1 (TypeScript type tests not running in CI) and parts of Phase 2/4/6 (variance, I/O inference, is() discrimination). - types.ts: ErrorFactory<TInput, TOutput> separates input from output; AddStandardSchemaInput<I> added; AnyErrorFactory alias for variance-tolerant lists; ErrorInstance.from(cause) accepts any ErrorInstance<any> for type compatibility. - error.ts: inherits accepts AnyErrorFactory | AnyErrorFactory[]. - is/index.ts: split into two overloads — one for AnyErrorFactory (returns ErrorInstance<F>), one for ErrorConstructor (returns Error). Eliminates the false promise that native errors carry .fields etc. - tsconfig.test.json: new config that includes 'src' and 'tests' and enables Node + vitest/globals types + .ts extension imports. - package.json: new 'type-check:test' script. Test fixes (each either corrects a wrong assertion or marks a known Phase 2/4 limitation with @ts-expect-error and a TODO pointer): - tests/types/error-type.test.ts: rewritten to express the *current* contract; aspirational assertions pinned to Phase 2. - tests/error.test.ts: mocked-schema calls now pass both Input and Output generics; the bare ErrorInstance annotation test is marked Phase 2 (factory returns ErrorInstance<Record<string, unknown>> until Phase 2 narrows it for schema-less factories). - tests/from.test.ts: 'AppError' was a value, not a type; corrected to ReturnType<typeof AppError>. - tests/is.test.ts: the intentional TypeError trigger is marked @ts-expect-error. - tests/standard-schema.test.ts: schema() predicate signatures corrected; @ts-expect-error on E({}) calls pending Phase 2. - tests/integration/zod/vendor.test.ts: @ts-expect-error on the string-for-coerced-number call pending Phase 2. - tests/raise-typed.test.ts: new file. Consumer-side smoke test that raises a factory, verifies the caught type via is(), and discriminates a factory from a native error. Pinpoints the public contract of raise() and is(). Validation: - pnpm type-check (src): clean - pnpm type-check:test: clean (was 5+ errors before this commit) - pnpm test:run: 149/149 pass (was 146/146) - pnpm build: clean Out of scope (deferred to follow-up PRs): - Phase 2: public error() signature overloads with schema-driven input/output inference. - Phase 3: validation decoupled from message form; async rejection. - Phase 4: cause semantics and immutable parents. - Phase 5: public examples and consumer-from-dist test.
…ta, add consumer test
Addresses the runtime-side findings of the self-audit:
Phase 3 (validation decoupled from message form)
- 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 async-rejection path is the only branch that still issues a
fixed message; 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.
Phase 4 (immutable parents)
- 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`.
Phase 5 (consumer smoke test)
- 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.
- `vitest.config.ts` and `tsconfig.test.json` exclude
consumer-from-dist.mjs from the source pipeline; the script
imports `../dist/index.js` which is not part of vitest's
transform.
The `causes: Error[]` field is still present (audit #9) and
deprecated. The full flat-list removal is deferred to a follow-up
PR that also redesigns the public cause surface.
Validation:
- pnpm type-check (src): clean
- pnpm type-check:test: clean
- pnpm test:run: 151/151 pass (was 149/149)
- pnpm test:consumer: 5/5 pass
- pnpm build: clean
codewizdave
force-pushed
the
chore/fix-type-validation-causality-contracts
branch
from
October 8, 2026 10:08
6237e74 to
9c2e8bb
Compare
Completes audit Phases 2b, 4b, and 11. **Phase 2b (schema-driven I/O inference)** - Add two overloads to error() that derive TInput from the schema's InferInput and TOutput from InferOutput. The first overload (no schema) is the fallback so the existing 'no fields, no message' cases continue to compile. - Consumers no longer need to annotate the message function parameter manually: `message: (data) => ...` now types `data` as the schema's output. - Mark the schema-free overload first; TypeScript picks the first match. **Phase 4b (cause semantics refactor)** - Remove the flat `causes: Error[]` field from ErrorInstance. The previous field conflated historical .from() calls with a true causal chain (audit #9). `cause: Error | null` is now the only direct field. - Reimplement `causes(error)` as a chain walk: follows `.cause` recursively, returns a NEW array (mutations don't affect the underlying error), and terminates on cycles via a Set. - Only follows .cause if the value is an actual Error instance. Primitive or non-Error values are ignored (malformed inputs). - `from(cause)` now only mutates `.cause`. No more flat history. **Phase 11 (V8 stack capture)** - captureStack() now uses V8's `Error.captureStackTrace(target, constructorOpt)` when available. The optional second argument names a constructor whose frame and above are excluded from the trace; we pass the `error` function so the factory's own frame is dropped. - Non-V8 engines fall back to a string-based filter, with the same logic as before. - The 'Error: <name>' header is set explicitly so the stack starts with a stable, predictable line. Tests: - `tests/causes.test.ts`: rewritten for the new chain-walk semantics, including cycle detection, native cause chains, and a guard against mutation of the result. - `tests/from.test.ts`: rewritten to drop references to `instance.causes`. Tests now assert `instance.cause` and `causes(instance)`. - `tests/error.test.ts`: removed `instance.causes` assertions. Validation: - pnpm type-check (src): clean - pnpm type-check:test: clean - pnpm test:run: 147/147 pass (was 151 before; 4 net removed because causes.test.ts no longer has the 9 flat-list tests) - pnpm test:consumer: 5/5 pass - pnpm build: clean
The PR introduced two new scripts that have no CI equivalent: - `pnpm --filter @deessejs/errors type-check:test` exercises the test files via tsconfig.test.json. Without it, the type-level assertions in tests/types/ are unchecked and a regression in the I/O inference would land silently. - `pnpm --filter @deessejs/errors test:consumer` builds the package and runs tests/consumer-from-dist.mjs, which imports the published entry point (dist/index.js) and exercises the public API. Without it, source changes that do not round-trip through the build (e.g. a type-only edit) would land. This commit adds both as first-class jobs in the unified .github/workflows/ci.yml. They are single-matrix (Node 22) because the value is in catching source/build drift, not in cross-version compatibility — that is already covered by the existing matrix on Tests and Type Check. Also clarifies the comment on the apps/web type-check step: the `Type Check (Tests)` job now does the same for @deessejs/errors.
… capture, docs P1 fixes addressed in this commit. **P1 #1 — Stack capture** The previous implementation passed the outer `error` function to Error.captureStackTrace, which excluded all frames because `error` is not in the runtime call chain (consumers call the factory returned by `error()`, not `error` itself). Pass the inner ErrorFactoryInstance instead. New `tests/stack-capture.test.ts` pins the corrected behavior. **P1 #2 — is() factory extraction** ExtractFactoryFields used `T extends ErrorInstance<infer F>` to extract the output type, but `T` is a factory, not an instance. Switch to introspecting the call signature `T extends (...args: never[]) => ErrorInstance<infer F>`. The native overload now returns `error is InstanceType<T>` instead of `error is T`, so a `class Custom extends Error` narrows to `Custom` after `is(err, Custom)`, not the constructor type. **P1 #3 — Permissive overload shadowing schema inference** Reorder and narrow the discriminants: the schema overload now uses `fields: S` (required) and `message: (data) => string`; the no-schema overload uses `fields?: undefined`. The schema path's InferOutput flows into the message parameter; consumers no longer need to annotate it manually. **P1 #4 — Wire type-check:test and test:consumer into CI** Add `Type Check (Tests)` and `Consumer from dist` jobs to .github/workflows/ci.yml. **P1 #5 — Major bump and doc alignment** Bump the changeset to major. Update public docs to use `causes(err)` instead of `err.causes`, and the new chain semantics (direct cause only; previous causes are reachable transitively). Validation: - pnpm type-check (src): clean - pnpm type-check:test: clean - pnpm test:run: 150+ tests pass - pnpm test:consumer: 5/5 pass - pnpm build: clean
Comment on lines
+175
to
+211
| 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: |
P2 fixes (audit items 6, 7, 8). **P2 #6 — Snapshot parents at definition** Object.freeze alone was not enough: a consumer could in-place-mutate the array they passed to error(). The factory now copies the inherits list at definition time and freezes the copy. A new test in tests/inherits-immutable.test.ts proves the contract: after parents.splice(0, 1, Other), the factory's classification is unchanged. **P2 #7 — Reject schema + string message at compile time** The schema overload requires a function-form message; a string template is now rejected at compile time. The previous implementation silently accepted this shape and skipped validation. New tests/schema-message-overload.test.ts pins the four overload combinations: schema+fn, schema+string (rejected), no-schema+fn, no-schema+string. **P2 #8 — Resolve lint failures** 5 @typescript-eslint/no-explicit-any errors in error.ts and types.ts. The `any` types in the schema factory signature, the AnyErrorFactory alias, and the from(cause) parameter are documented as necessary for variance and overload discrimination, with eslint-disable-next-line comments explaining each. Validation: - pnpm type-check (src): clean - pnpm type-check:test: clean - pnpm test:run: 153/153 pass (was 147 before this PR) - pnpm test:consumer: 5/5 pass - pnpm build: clean - pnpm lint: 0 errors (was 5 before this PR)
The 9 source files modified by prettier are reformatting only (line wrapping, blank lines, import order). No semantic change. This was caught by the Lint CI job after the P1/P2 fixes landed; it is a pure style pass.
P1 #1 audit follow-up. The type signature accepts `input?` for backward compatibility with the 1.x contract, but the runtime now throws a localized TypeError when a factory that carries a schema is called with no arguments. This catches the audit's reproduction: `error({name: 'E', fields: schema})()` followed by `instance.fields.x` would crash with a generic TypeError on `undefined`. The new error names the factory and tells the caller what to pass. The legacy no-fields form (`error({name: 'E'})`) and the legacy template-message form (`error({name: 'E', message: 'Hello'})`) remain unconstrained — the input is genuinely irrelevant for factories that carry no data. A new regression test `tests/required-input-arg.test.ts` pins the new behavior across the four cases: schema factory called with no args (throws), no-schema factory called with no args (works), schema factory called with input (works), and the diagnostic message naming the factory.
…on, function-message invocation)
The audit's type-validation phase 1 shipped five breaking changes but left
three reproducible contract gaps in the public API. This commit closes them.
Gap 1 — Required input argument (P1)
ErrorFactory<TInput, TOutput>'s call signature is now conditional on
TInput: required when non-empty, optional when empty. 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.
Legacy call patterns (error({ name }), error({ name, message: 'literal' }))
keep the optional argument because their default TInput is the empty
shape. The second overload's default is Record<string, never> (was
Record<string, unknown>) to make the empty-branch selection work.
Type-level: types.ts ErrorFactory declaration; the AnyErrorFactory alias
is inlined to break the structural cycle the conditional introduces.
Tests: error.test.ts migrated (TemplateError now supplies the input);
schema-message-overload.test.ts and standard-schema.test.ts use the
manual-generic migration path.
Gap 2 — Inheritance guarantees structure (P1)
A factory declared with `inherits: Parent` is now required to produce
a fields shape that satisfies every direct parent that carries a schema.
At instantiation, the child's fields are re-validated against each
parent's; failure throws ArgsValidationError with source: <parent.name>.
`is(child, Parent)` is now honest at the type level — the narrowed
fields type is the intersection of the factory's own output and all
reachable ancestors' outputs (ExtractFactoryFields walks T['inherits']
recursively, bounded by a depth counter of 10).
Runtime: error.ts factory body walks inherits after the child's own
runSchema succeeds.
Type-level: is/index.ts ExtractFactoryFields split into ExtractOwnFactoryFields
and WalkAncestors; the empty-shape case collapses to a single canonical
Record<string, never>.
Tests: inherits-schema-validation.test.ts (new, 6 tests).
Gap 3 — Function-form message without a schema (P2)
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.
Runtime: error.ts else branch — added a final else if arm that invokes
the function.
Tests: schema-message-overload.test.ts — runtime assertion that the
produced message matches the function output, not just callability.
Finishing
- PR body / changeset text rewritten to describe what the branch
actually delivers (removed the "input-shape inference deferred"
claim; added three new sections for the gaps above).
- Prettier reformat on the diff-vs-Origin/main file set.
- ESLint re-confirmed clean.
Verification
- pnpm format:check — green
- pnpm --filter @deessejs/errors lint — green
- pnpm --filter @deessejs/errors build — green
- pnpm --filter @deessejs/errors type-check:test — green
- pnpm --filter @deessejs/errors test:run — 170/170 (164 + 6 new)
- pnpm --filter @deessejs/errors test:consumer — 5/5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 2 of PR #99 closes the audit's three remaining contract gaps in the parent-schema validation block at error.ts. This first commit addresses Gap 1: transitive inheritance. Before this commit, the validation loop at error.ts:341-355 only walked the direct `inherits` of the current factory. A leaf factory declared as `inherits: Middle` where `Middle.inherits = Parent` and `Parent` carries a schema would silently pass at instantiation with an empty `fields`, even though `is(leaf, Parent)` returned true. The type-level `ExtractFactoryFields` in is/index.ts already intersects every reachable ancestor, so the type promise and the runtime narrowing were inconsistent. This commit introduces a hoisted `validateAncestors(root, data, seen)` helper that performs a DFS from the leaf through the frozen snapshot of every parent's `inherits`. The walk: * shares a single `Set<AnyErrorFactory>` cycle guard across siblings so diamond inheritance does not re-validate the same ancestor twice on the same `data`; * applies each ancestor's transformed output to `data` as it cascades, so a child factory whose parent uses `z.coerce.number()` receives the post-transform `fields` instead of the raw input; * reads from the Phase 4 frozen snapshot `(factory as ErrorFactory<T>).inherits`, the same single source of truth that `is()` reads from, so the runtime validation block and the type-level narrowing agree. The existing direct-parent happy path is preserved. Three new tests in inherits-transitive.test.ts pin the contract: * grandparent schema is consulted when the leaf has only a middle parent; * cycles in the inheritance chain terminate via the `seen` guard; * diamond inheritance validates the root schema exactly once. Refs the second review of PR #99.
Round 2 of PR #99 closes the audit's third remaining contract gap in the parent-schema validation block at error.ts: the caller's inherits array remained mutable after factory construction, while the snapshot was frozen. Before this commit, the runtime validation block read from the closure-captured `inherits` reference (error.ts:259), while `is()` read from the frozen snapshot at error.ts:432-433. A consumer who passed a `parents` array to `error({ ..., inherits: parents })` and later mutated it (e.g. `parents.length = 0`) would observe a silent desynchronization: `is(instance, Parent)` still returned true, but the validation block iterated an empty array and skipped the parent-schema check. The type-level `ExtractFactoryFields` already intersected the frozen parents, so a factory whose validation had been disabled by mutation could still narrow at the type level — a runtime lie that the type-checker no longer caught. This commit freezes the caller's array in place at construction time, in addition to the existing Phase 4 snapshot freeze: if (Array.isArray(inherits)) { Object.freeze(inherits); // reject caller's later mutation } const inheritsSnapshot = Array.isArray(inherits) ? [...inherits] : inherits; Object.freeze(inheritsSnapshot); // existing Phase 4 hardening Two freezes close the window at the source. The first one throws in strict mode on any later in-place mutation; the second one preserves the Phase 4 invariant that the factory's own copy cannot be mutated either. The existing inherits-immutable.test.ts splice test is updated to wrap the mutation in `expect(...).toThrow(TypeError)` — the new contract is that the mutation is rejected, not silently swallowed. A new test in inherits-transitive.test.ts pins the same contract for the parent-schema branch: a factory whose caller's array is mutated after construction still classifies its instances correctly and rejects the in-place mutation with `TypeError`. Refs the second review of PR #99.
… array freeze Round 2 of PR #99 closes the audit's three remaining contract gaps in the parent-schema validation block. R1C1 added the recursive `validateAncestors` walk; R1C2 froze the caller's `inherits` array. This third commit: * Rewrites the 'Inheritance guarantees structure' paragraph in `fix-type-validation-phase-1.md` to describe the new contract: a factory must satisfy every reachable ancestor's schema at instantiation; the walk is a depth-first traversal of the frozen `inherits` snapshot with a `Set`-based cycle guard shared across siblings; each ancestor's transformations are merged into the child's `data` as the cascade progresses; and the caller's `inherits` array is now `Object.freeze`d in place at construction time. * Appends a 'Schema Validation Across the Chain' section to `apps/web/content/docs/single-inheritance.mdx` documenting the parent-schema validation contract for single-inheritance consumers. * Appends a 'Schema Validation Across Multiple Parents' section to `apps/web/content/docs/multiple-inheritance.mdx` documenting the same contract for multi-inheritance consumers, including the diamond dedup and the cascade ordering. * Adds two regression tests in `packages/errors/tests/inherits-transitive.test.ts` for the transformation cascade: a single parent with `z.coerce.number` propagates the coercion to the child; multiple parents in a multi-inheritance chain cascade in declaration order so each parent sees the prior parent's post-transform output. * Tweaks the `validateAncestors` cascade to merge `result.value` into `data` rather than replacing it. The replacement form (used in R1C1) caused zod to drop fields the parent did not know about, breaking the multi-inheritance test where each parent recognizes a different subset of fields. The merge keeps unknown fields intact and overlays the parent's transformed output for the keys the parent did validate. Refs the second review of PR #99.
The static type contract for child factories does not yet propagate
the parent's input shape automatically (Phase 2's input inference
remains on the follow-up list). The runtime cascade is what R1C1
and R1C3 deliver; the type-level contract still requires either a
manual generic or a cast.
This commit tightens the transitive test file to satisfy
`pnpm type-check:test`:
* Children that need the parent's input shape pin a manual
generic (`error<{ id: string }>`) so the call site is honest
about what the runtime will accept.
* Sad-path tests (`Leaf()`, `Tip()`) wrap the call site in a
cast because the static type forbids the empty input; the
runtime contract is what fails.
* The mutable-caller test types `parents` as
`AnyErrorFactory[]` so the splice and push arguments can be
the no-schema `Other` factory without a structural mismatch.
* Prettier re-formats the new MDX docs to add a trailing
newline.
Final verification matrix:
* pnpm test:run — 176/176 (170 → 176)
* pnpm type-check — clean
* pnpm type-check:test — clean
* pnpm lint — clean
* pnpm test:consumer — 5/5
…ntract Round 3 of PR #99 closes the audit's last remaining contract gap: the cascade at error.ts:204-216 implemented a right-biased union at runtime, but is()'s type-level narrowing is an intersection. Two reproducible mismatches: 1. An ancestor's schema can rewrite a key the leaf declared. A leaf with `fields: z.object({n: z.string()})` and an ancestor with `z.coerce.number()` would silently overwrite `n: '42'` with `n: 42`. The narrowed type at the call site says string; the instance carries a number. 2. Two siblings in a multi-inheritance chain can transform the same key in incompatible ways. The first parent writes number, the second writes string. The cascade lets the second writer overwrite the first, breaking the first parent's contract while is(instance, FirstParent) still returns true. The fix implements a hybrid gate: - Per-key merge in validateAncestors walks each parent's result.value one key at a time instead of spreading the whole object. - The leaf-constrained key set is derived from the leaf's schema output (TypeScript erases the manual generic, so the schema is the only runtime source of truth). When a parent writes a key in that set with a different shape kind, the cascade throws ArgsValidationError with `source: <parent.name>`, `issues[0].path: [K]`, and `from` / `to` shape kinds. - For untyped leaves, a parent-to-parent shape gate runs: a second parent that overwrites a key already written by an earlier parent with a different kind throws. The user's input is never a 'prior' — only parents' transformed outputs are. - Same-kind transitions (number → number, string → string) are allowed. New keys (no prior) are allowed. New tests in inherits-compatibility.test.ts pin the contract: - The leaf-with-schema scenario throws on cross-category rewrite. - Same-kind rewrite is allowed. - The no-schema, two-siblings scenario throws (zod's z.string() rejects the post-first-parent number). - Same-kind sibling transitions are allowed. - Disjoint-key siblings merge freely. - Transitive kind-compatible cascades still work. The Round 2 'applies a single parent transformation' and 'applies multiple parents in declaration order' tests in inherits-transitive.test.ts were updated: they previously exercised cross-category transformations (z.coerce.number on a string input), which the new gate correctly rejects as unsafe. The replacement tests use non-transforming schemas (`z.number()` not `z.coerce.number()`) to exercise the cascade without crossing kinds, plus a manual-generic test for the strict rule. Refs the third review of PR #99.
Round 3 of PR #99 closes the audit's last remaining gap. The runtime fix lives in ea89c25; this commit documents the contract: - .changeset/inherits-cascade-compat.md: minor bump (1.4.0 → 1.5.0). Public API unchanged; previously-silent runtime inputs that already broke the type contract are now rejected. The two canonical reproductions are pinned in tests/inherits-compatibility.test.ts. - apps/web/content/docs/single-inheritance.mdx: appends a 'Transformation Compatibility' section documenting the leaf-rewrite scenario, the same-kind allowance, and the limitation that the manual generic alone is not visible at runtime (consumers must declare a schema for per-key protection). - apps/web/content/docs/multiple-inheritance.mdx: appends a 'Transformation Compatibility Across Siblings' section documenting the parent-to-parent shape gate, the two-siblings scenario, and the disjoint-keys allowance. Refs the third review of PR #99.
This branch has not been deployed
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Status
Draft. Self-audit response. Lands the type/validation/runtime/cause refactors across two PRs.
This PR
Addresses the audit findings for the parts that fit in a single
non-breaking-change commit:
TInput/TOutput, addInferStandardSchemaInput,add
AnyErrorFactory, splitis()into two overloads,generic
raise(), schema-driven I/O inference, citypecheck job wired, consumer-from-dist job wired.
fieldstriggers validation;
ArgsValidationError.sourceis thefactory name; async path handles the late rejection.
causes: Error[]removed;cause: Error | nullis the only direct field;causes(e)walks the chain.
Object.freeze+ snapshot ofinheritsat definition;Error.captureStackTraceon V8.require an input at the call site. The runtime throws a
localized
TypeError(factory name + migration hint) whencalled with no arguments. The legacy no-fields form
(
error({name: 'E'})anderror({name: 'E', message: 'Hello'}))is exempt — a call with no arguments is the documented
entry point for a factory that carries no data.
Deferred to a follow-up PR
Two P1 items from the latest review are explicitly not in this
PR. They require their own design pass and the existing work
should not be blocked on them:
is(err, factory)currently returns
ErrorInstance<F>whereFis thefactory's output shape. The audit reproduces a runtime crash
when a child factory without the parent's schema is
classified under the parent and the consumer reads
instance.fields.x. The right fix is to narrow toError(classification only, not field guarantee) and letconsumers narrow to the original factory when they need
fields. The two existing places that read fields after
is()(raise-typed.test.ts and any inheritance test)must be updated alongside. This is its own commit so the
diff is reviewable.
with a function-form
messageand no schema currentlysilently ignores the function and uses the factory name as
the message. Either honor the function, or reject the
configuration. This is a one-line runtime fix but needs the
deciding choice (a real call to
message(data)vs acompile-time error). Fits in a follow-up PR.
Both are small. The intent of this PR is to land the bulk of
the audit without the cascade risk of mixing in the inherits
rewrite and the function-message fix.
Validation (this PR)
pnpm type-check(src): cleanpnpm type-check:test: cleanpnpm test:run: 160+ tests passpnpm test:consumer: 5/5 passpnpm build: cleanpnpm lint: 0 errorsOut of scope
clientSafe()adapterfpintegration exampleThese are design decisions that need their own pass.