Skip to content

fix(v4): type safeParse errors by input, not output (#5195)#5922

Closed
dokson wants to merge 1 commit into
colinhacks:mainfrom
dokson:fix/safeparse-error-input-type
Closed

fix(v4): type safeParse errors by input, not output (#5195)#5922
dokson wants to merge 1 commit into
colinhacks:mainfrom
dokson:fix/safeparse-error-input-type

Conversation

@dokson

@dokson dokson commented May 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #5195. Related ecosystem evidence: nicoespeon/zod-v3-to-v4#30 and #35 — the official v3→v4 migration tool had to drop v3's input generic when rewriting SafeParseReturnType<I, O> to ZodSafeParseResult<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(...).error is currently typed as ZodError<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:

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 errors = pipe.safeParse({ a: 10 })?.error;
if (errors) {
  z.treeifyError(errors).properties?.a; // ← flagged as a type error, but real
  z.treeifyError(errors).properties?.b; // ← accepted, but does not exist at runtime
}

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:

// before
export type SafeParseResult<T> = SafeParseSuccess<T> | SafeParseError<T>;

// after
export type SafeParseResult<O, I = O> = SafeParseSuccess<O> | SafeParseError<I>;

Then thread core.input<this> through:

  • safeParse / safeParseAsync / spa overloads on ZodType (classic) and ZodMiniType (mini)
  • safeEncode / safeDecode (and async variants) — error keyed by the value being parsed
  • inferFlattenedErrors / inferFormattedError v3-compat aliases so error.flatten() / error.format() typings agree

The 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 where input === output.
  • result.success / result.data typings unchanged.
  • result.error typing 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.
  • Existing inferFlattenedErrors test in error.test.ts now compiles cleanly (it was already asserting the correct runtime shape — only the inferred type was lying).

Local results

Test Files  1 failed | 338 passed (339)
     Tests  1 failed | 3792 passed (3793)
Type Errors no errors

The single failing test (src/v4/classic/tests/datetime.test.ts > redos checker) is not caused by this PR. It's a recheck library 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 of safeParse typings 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 latest job, also unrelated to this change: it's the repo-wide baseUrl deprecation that surfaces under TS 6 since e58ea4d. 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.ts passes, inferFlattenedErrors test passes, no type errors anywhere in the repo.

@pullfrog

pullfrog Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

New pull request. Leaping into action...

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Added safeParseAsync test covering pipe schemas with divergent input/output types
  • Added safeDecode and safeEncode tests 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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@pullfrog

pullfrog Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

TL;DR — When safeParse (and related safe* methods) fails, the error type is now parameterized by the schema's input type rather than its output type. This fixes #5195 where error trees for piped/transformed schemas exposed output-shape keys that don't exist on the input the user actually provided.

Key changes

  • Add separate input type parameter to SafeParseResultSafeParseResult<O, I = O> now carries both an output type (for data on success) and an input type (for error on failure), defaulting I to O for backward compatibility.
  • Thread core.input<T> through all safe function signatures* — safeParse, safeParseAsync, safeDecode, safeDecodeAsync, safeEncode, and safeEncodeAsync all pass the appropriate input type as the error parameter across classic, mini, and core layers.
  • Fix inferFlattenedErrors / inferFormattedError — These deprecated compat types now derive from core.input<T> instead of core.output<T>.
  • Add regression tests for [V4] ZodSafeParseResult only keeps the output type of the schema #5195 — Covers pipe, codec encode/decode, async variants, and backward-compat for the single-arg ZodSafeParseResult<T> form.

Summary | 7 files | 1 commit | base: mainfix/safeparse-error-input-type


SafeParseResult now separates success and error types

Before: SafeParseResult<T> used a single type parameter for both the success data and the failure error, meaning the error tree was typed against the output shape.
After: SafeParseResult<O, I = O> uses the output type for data and the input type for error, so error trees correctly reflect the shape users actually pass in.

The core change lives in util.ts (SafeParseResult<O, I = O>) and the classic wrapper (ZodSafeParseResult<O, I = O>). The default I = O preserves backward compatibility for schemas with no transform—existing code using ZodSafeParseResult<string> continues to compile without changes.

Every safe* function signature across classic, mini, and core now explicitly passes core.input<T> (or core.output<T> for encode operations where the "input" is the output direction) as the second type argument.

How does this affect codec encode/decode?

For safeEncode, the "input" to validation is the output type (you're validating a value of core.output<T> before encoding it), so the error is typed as ZodError<core.output<T>>. For safeDecode, the input to validation is core.input<T>, so the error is ZodError<core.input<T>>. Each direction gets an error tree that matches the shape being validated.

core/util.ts · core/parse.ts · classic/parse.ts · safeparse-error-input.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, defaulting I to O for backward compatibility
  • Threaded core.input<this> through safeParse, safeParseAsync, spa, safeDecode, safeDecodeAsync overloads on ZodType and ZodMiniType
  • Threaded core.output<this> as the error generic for safeEncode / safeEncodeAsync (encode validates output)
  • Updated inferFlattenedErrors / inferFormattedError compat aliases from core.output<T> to core.input<T>

Pullfrog  | View workflow run | Using 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)
@colinhacks

Copy link
Copy Markdown
Owner

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).

@colinhacks colinhacks closed this May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[V4] ZodSafeParseResult only keeps the output type of the schema

2 participants