fix(v4): type safeParse errors by input, not output (#5195)#5922
Conversation
|
New pull request. Leaping into action...
|
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Added
safeParseAsynctest covering pipe schemas with divergent input/output types - Added
safeDecodeandsafeEncodetests covering codec schemas, verifying error trees are keyed by the validated type (input for decode, output for encode)
The new tests complement the existing type changes well — they exercise both runtime assertions and compile-time @ts-expect-error guards for the codec path, which was previously untested.
Claude Opus | 𝕏
|
TL;DR — When Key changes
Summary | 7 files | 1 commit | base:
|
d48d0f1 to
80434f5
Compare
80434f5 to
33848b7
Compare
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Squash-rebased the two prior commits (type signature changes + tests) into a single clean commit on top of current
main - Generalised
SafeParseResult<T>→SafeParseResult<O, I = O>in both core (util.ts) and classic (parse.ts) layers, defaultingItoOfor backward compatibility - Threaded
core.input<this>throughsafeParse,safeParseAsync,spa,safeDecode,safeDecodeAsyncoverloads onZodTypeandZodMiniType - Threaded
core.output<this>as the error generic forsafeEncode/safeEncodeAsync(encode validates output) - Updated
inferFlattenedErrors/inferFormattedErrorcompat aliases fromcore.output<T>tocore.input<T>
Claude Opus | 𝕏
When a schema's input differs from its output (pipe / transform / codec), `safeParse(...).error` is currently typed as `ZodError<output>` even though the issues it carries are paths in the input space (validation runs there). `treeifyError(result.error)` therefore infers a tree keyed by output properties that don't exist at runtime, while the input-keyed properties that DO exist are flagged as type errors. OP's reproduction (colinhacks#5195): type A = { a: number }; type B = { b: string }; const pipe = z.object({ a: z.number().max(9) }) .pipe(z.transform<A, B>(({ a }) => ({ b: a.toString() }))); const tree = z.treeifyError(pipe.safeParse({ a: 10 }).error!); tree.properties?.a; // (real) — but flagged as a type error tree.properties?.b; // (does not exist at runtime) — accepted Generalise `SafeParseResult` (and the classic mirror `ZodSafeParseResult`) to carry the input as a second generic that defaults to the output: type SafeParseResult<O, I = O> = SafeParseSuccess<O> | SafeParseError<I>; then thread `core.input<this>` through the `safeParse`/`safeParseAsync`/`safeEncode`/`safeDecode` overloads on `ZodType` (classic) and `ZodMiniType` (mini), plus the `inferFlattenedErrors` / `inferFormattedError` v3-compat aliases so `error.flatten()` / `error.format()` typings agree. The two-generic shape mirrors v3's `SafeParseReturnType<I, O>`, which v4 collapsed. `nicoespeon/zod-v3-to-v4#35` explicitly documents the migration as lossy and points at colinhacks#5195 as the upstream gap, so the regression is now visible to any user running the official migration tool. Backward-compatible: the second generic defaults to the first, so all existing one-arg uses keep their previous typing for non-pipe schemas (where input === output). Only pipe / transform / codec schemas see a typing change, and they get the typing users always expected — the previous typing was unsound and demonstrably broke `treeifyError` consumers. Test coverage: - sync `safeParse` on a pipe: error tree keyed by input, output keys rejected at type-level - `safeParseAsync` on a pipe: same propagation through the async path - `safeDecode`: error tree keyed by codec input (side being validated) - `safeEncode`: error tree keyed by codec output (side being validated in reverse)
33848b7 to
090b052
Compare
|
Too breaky and not what most people want. Zod had to make a call here. One counterexample doesn't merit a change in behavior. And I suspect it's more common for the input to be loose (z.coerce, z.preprocess) than the output (piping into transforms). |
Fixes #5195. Related ecosystem evidence:
nicoespeon/zod-v3-to-v4#30and#35— the official v3→v4 migration tool had to drop v3's input generic when rewritingSafeParseReturnType<I, O>toZodSafeParseResult<O>, and explicitly flagged this issue as the upstream gap.Discussion seed in #5195 (comment). Opening the PR for concrete review surface; happy to close it if the maintainer would rather keep the current shape.
Problem
When a schema's input differs from its output (pipe / transform / codec),
safeParse(...).erroris currently typed asZodError<output>even though the issues it carries are paths in the input space (validation runs there).z.treeifyError(result.error)therefore infers a tree keyed by output properties that don't exist at runtime, while the input-keyed properties that DO exist are flagged as type errors.OP's reproduction:
The maintainer-suggested workaround
z.treeifyError<unknown>(errors)is fine for one-off code but doesn't scale into codemods or library APIs that need typed error trees per schema.Fix
Generalise the success/error split so error carries the input generic, defaulting to the output for backward compatibility:
Then thread
core.input<this>through:safeParse/safeParseAsync/spaoverloads onZodType(classic) andZodMiniType(mini)safeEncode/safeDecode(and async variants) — error keyed by the value being parsedinferFlattenedErrors/inferFormattedErrorv3-compat aliases soerror.flatten()/error.format()typings agreeThe two-generic shape mirrors v3's
SafeParseReturnType<I, O>exactly, which v4 collapsed. Codecs and migrations that used to work in v3 keep working with the same intent.Backward compatibility
ZodSafeParseResult<X>(single arg) defaults to<X, X>— identical typing for the 98% of schemas whereinput === output.result.success/result.datatypings unchanged.result.errortyping only changes for pipe / transform / codec schemas, where the previous typing was unsound: any user code that relied on it was already broken at runtime (the OP's reproduction is exactly that case).Tests
packages/zod/src/v4/classic/tests/safeparse-error-input.test.ts— locks in the OP reproduction (tree keyed by input, output keys rejected) and verifies the one-arg form still types correctly for simple schemas.inferFlattenedErrorstest inerror.test.tsnow compiles cleanly (it was already asserting the correct runtime shape — only the inferred type was lying).Local results
The single failing test (
src/v4/classic/tests/datetime.test.ts > redos checker) is not caused by this PR. It's arechecklibrary bug on Windows that fires for every contributor on Windows, regardless of branch —recheck's jar resolution uses a forward-slash regex that no-ops on backslash paths. This is independent ofsafeParsetypings and is fixed by #5919. Same test passes on Linux CI today.Diff stats: 7 files, +62 / -25 (most of which is overload signatures gaining a second generic).
Note about CI
CI on this PR will also show a red
Test with TypeScript latestjob, also unrelated to this change: it's the repo-widebaseUrldeprecation that surfaces under TS 6 sincee58ea4d. Every PR opened since that commit fails the same way. #5921 (small workflow patch) addresses it; once that lands the noise on this PR will go away.Sanity-checked locally on TS 6.0.3 with #5919 + #5921 patches applied: full suite green,
safeparse-error-input.test.tspasses,inferFlattenedErrorstest passes, no type errors anywhere in the repo.