Repository: colinhacks/zod
Pull request #4384 (2025-05-16 19:52:43)

Improve discriminateduion errors

coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are disabled on base/target branches other than the default branch.
> 
> 
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=colinhacks/zod&utm_content=4384):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->

Patch:
diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts
index 60d07a6d..b2187d22 100644
--- a/packages/zod/src/v4/classic/schemas.ts
+++ b/packages/zod/src/v4/classic/schemas.ts
@@ -1613,7 +1613,7 @@ export const ZodTransform: core.$constructor<ZodTransform> = /*@__PURE__*/ core.
           _issue.code ??= "custom";
           _issue.input ??= payload.value;
           _issue.inst ??= inst;
-          _issue.continue ??= !def.abort;
+          _issue.continue ??= true;
           payload.issues.push(util.issue(_issue));
         }
       };
@@ -1632,13 +1632,11 @@ export const ZodTransform: core.$constructor<ZodTransform> = /*@__PURE__*/ core.
 );
 
 export function transform<I = unknown, O = I>(
-  fn: (input: I, ctx: core.ParsePayload) => O,
-  params?: string | core.$ZodTransformParams
+  fn: (input: I, ctx: core.ParsePayload) => O
 ): ZodTransform<Awaited<O>, I> {
   return new ZodTransform({
     type: "transform",
     transform: fn as any,
-    ...util.normalizeParams(params),
   }) as any;
 }
 
@@ -2011,16 +2009,12 @@ export function superRefine<T>(
   return ch;
 }
 
-// instanceof
-abstract class Class {
-  constructor(..._args: any[]) {}
-}
 type ZodInstanceOfParams = util.Params<
   ZodCustom,
   core.$ZodIssueCustom,
   "type" | "check" | "checks" | "fn" | "abort" | "error" | "params" | "path"
 >;
-function _instanceof<T extends typeof Class>(
+function _instanceof<T extends typeof util.Class>(
   cls: T,
   params: ZodInstanceOfParams = {
     error: `Input not instance of ${cls.name}`,
@@ -2063,13 +2057,11 @@ export function json(params?: string | core.$ZodCustomParams): ZodJSONSchema {
 }
 
 // preprocess
-export interface ZodPreprocessParams extends core.$ZodTransformParams {}
 
 // /** @deprecated Use `z.pipe()` and `z.transform()` instead. */
 export function preprocess<A, U extends core.$ZodType>(
   fn: (arg: unknown, ctx: RefinementCtx) => A,
-  schema: U,
-  params?: ZodPreprocessParams
+  schema: U
 ): ZodPipe<ZodTransform<A, unknown>, U> {
-  return pipe(transform(fn as any, params), schema as any);
+  return pipe(transform(fn as any), schema as any);
 }
diff --git a/packages/zod/src/v4/core/api.ts b/packages/zod/src/v4/core/api.ts
index 0a6554a8..663397f8 100644
--- a/packages/zod/src/v4/core/api.ts
+++ b/packages/zod/src/v4/core/api.ts
@@ -1217,13 +1217,11 @@ export function _file(Class: util.SchemaClass<schemas.$ZodFile>, params?: string
 export type $ZodTransformParams = util.TypeParams<schemas.$ZodTransform, "transform">;
 export function _transform<I = unknown, O = I>(
   Class: util.SchemaClass<schemas.$ZodTransform>,
-  fn: (input: I, ctx?: schemas.ParsePayload) => O,
-  params?: string | $ZodTransformParams
+  fn: (input: I, ctx?: schemas.ParsePayload) => O
 ): schemas.$ZodTransform<Awaited<O>, I> {
   return new Class({
     type: "transform",
     transform: fn as any,
-    ...util.normalizeParams(params),
   }) as any;
 }
 
diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts
index 3a34948e..98b07a1d 100644
--- a/packages/zod/src/v4/core/schemas.ts
+++ b/packages/zod/src/v4/core/schemas.ts
@@ -1941,21 +1941,30 @@ export interface $ZodDiscriminatedUnion<T extends readonly $ZodType[] = readonly
   _zod: $ZodDiscriminatedUnionInternals<T>;
 }
 
+function matchDiscriminatorAtKey(input: any, key: PropertyKey, disc: util.DiscriminatorMapElement): boolean {
+  let matched = true;
+  const data = input?.[key];
+
+  if (disc.values.size && !disc.values.has(data)) {
+    matched = false;
+  }
+  if (disc.maps.length > 0) {
+    for (const m of disc.maps) {
+      if (!matchDiscriminators(data, m)) {
+        matched = false;
+      }
+    }
+  }
+
+  return matched;
+}
+
 function matchDiscriminators(input: any, discs: util.DiscriminatorMap): boolean {
   let matched = true;
   for (const [key, value] of discs) {
-    const data = input?.[key];
-
-    if (value.values.size && !value.values.has(data)) {
+    if (!matchDiscriminatorAtKey(input, key, value)) {
       matched = false;
     }
-    if (value.maps.length > 0) {
-      for (const m of value.maps) {
-        if (!matchDiscriminators(data, m)) {
-          matched = false;
-        }
-      }
-    }
   }
 
   return matched;
@@ -1969,8 +1978,9 @@ export const $ZodDiscriminatedUnion: core.$constructor<$ZodDiscriminatedUnion> =
     const _super = inst._zod.parse;
     const _disc: util.DiscriminatorMap = new Map();
     for (const el of def.options) {
-      if (!el._zod.disc) throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(el)}"`);
-      for (const [key, o] of el._zod.disc) {
+      const subdisc = el._zod.disc;
+      if (!subdisc) throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(el)}"`);
+      for (const [key, o] of subdisc) {
         if (!_disc.has(key))
           _disc.set(key, {
             values: new Set(),
@@ -1987,13 +1997,10 @@ export const $ZodDiscriminatedUnion: core.$constructor<$ZodDiscriminatedUnion> =
     }
     inst._zod.disc = _disc;
 
-    const discMap: Map<$ZodType, util.DiscriminatorMap> = new Map();
-    for (const option of def.options) {
-      const disc = option._zod.disc;
-      if (!disc) {
-        throw new Error(`Invalid discriminated union element: ${option._zod.def.type}`);
-      }
-      discMap.set(option, disc);
+    const subdiscs = def.options.map((o) => o._zod.disc!);
+    const discKeys = [...inst._zod.disc.keys()].filter((k) => subdiscs.every((d) => d.has(k)));
+    if (discKeys.length === 0) {
+      throw new Error(`Invalid discriminated union: no shared discriminator key: ${discKeys.join(", ")}`);
     }
 
     inst._zod.parse = (payload, ctx) => {
@@ -2009,15 +2016,24 @@ export const $ZodDiscriminatedUnion: core.$constructor<$ZodDiscriminatedUnion> =
       }
 
       const filteredOptions: $ZodType[] = [];
-      for (const option of def.options) {
-        if (discMap.has(option)) {
-          if (matchDiscriminators(input, discMap.get(option)!)) {
+      for (const k of discKeys) {
+        let matched = false;
+        for (const option of def.options) {
+          if (matchDiscriminatorAtKey(input, k, option._zod.disc!.get(k)!)) {
+            matched = true;
             filteredOptions.push(option);
           }
-        } else {
-          // no discriminator
-          filteredOptions.push(option);
         }
+        // no matching discriminator
+        if (!matched && !def.unionFallback)
+          payload.issues.push({
+            code: "invalid_union",
+            errors: [],
+            note: "No matching discriminator",
+            path: [k],
+            input,
+            inst,
+          });
       }
 
       if (filteredOptions.length === 1) return filteredOptions[0]._zod.run(payload, ctx) as any;
@@ -2026,14 +2042,6 @@ export const $ZodDiscriminatedUnion: core.$constructor<$ZodDiscriminatedUnion> =
         return _super(payload, ctx);
       }
 
-      // no matching discriminator
-      payload.issues.push({
-        code: "invalid_union",
-        errors: [],
-        note: "No matching discriminator",
-        input,
-        inst,
-      });
       return payload;
     };
   });
@@ -2834,7 +2842,6 @@ export const $ZodFile: core.$constructor<$ZodFile> = /*@__PURE__*/ core.$constru
 export interface $ZodTransformDef extends $ZodTypeDef {
   type: "transform";
   transform: (input: unknown, payload: ParsePayload<unknown>) => util.MaybeAsync<unknown>;
-  abort?: boolean | undefined;
 }
 export interface $ZodTransformInternals<O = unknown, I = unknown> extends $ZodTypeInternals<O, I> {
   def: $ZodTransformDef;
diff --git a/packages/zod/src/v4/core/util.ts b/packages/zod/src/v4/core/util.ts
index 404f78f8..affb0cd1 100644
--- a/packages/zod/src/v4/core/util.ts
+++ b/packages/zod/src/v4/core/util.ts
@@ -321,6 +321,7 @@ export function isObject(data: any): data is Record<PropertyKey, unknown> {
 
 export const allowsEval: { value: boolean } = cached(() => {
   try {
+    const F = Function;
     new F("");
     return true;
   } catch (_) {
@@ -328,8 +329,6 @@ export const allowsEval: { value: boolean } = cached(() => {
   }
 });
 
-export const F: typeof Function = Function;
-
 export function isPlainObject(data: any): data is Record<PropertyKey, unknown> {
   return typeof data === "object" && data !== null && Object.getPrototypeOf(data) === Object.prototype;
 }
@@ -808,3 +807,8 @@ export function cleanEnum(obj: Record<string, EnumValue>): EnumValue[] {
     })
     .map((el) => el[1]);
 }
+
+// instanceof
+export abstract class Class {
+  constructor(..._args: any[]) {}
+}
diff --git a/packages/zod/src/v4/mini/schemas.ts b/packages/zod/src/v4/mini/schemas.ts
index 5cfa4805..840f72ea 100644
--- a/packages/zod/src/v4/mini/schemas.ts
+++ b/packages/zod/src/v4/mini/schemas.ts
@@ -1154,13 +1154,11 @@ export const ZodMiniTransform: core.$constructor<ZodMiniTransform> = /*@__PURE__
 );
 
 export function transform<I = unknown, O = I>(
-  fn: (input: I, ctx: core.ParsePayload) => O,
-  params?: string | core.$ZodTransformParams
+  fn: (input: I, ctx: core.ParsePayload) => O
 ): ZodMiniTransform<Awaited<O>, I> {
   return new ZodMiniTransform({
     type: "transform",
     transform: fn as any,
-    ...util.normalizeParams(params),
   }) as any;
 }
 
diff --git a/play.ts b/play.ts
index e216e6ee..4567fd42 100644
--- a/play.ts
+++ b/play.ts
@@ -1,19 +1,12 @@
 import { z } from "zod/v4";
 
-const schema = z.object({
-  name: z.string(),
-  age: z.number(),
-});
-
-const data = {
-  name: "John Doe",
-  age: 30,
-};
-
-const result = schema.safeParse(data);
-
-if (result.success) {
-  console.log("Parsed data:", result.data);
-} else {
-  console.error("Validation errors:", result.error);
-}
+const schema = z.preprocess((data, ctx) => {
+  ctx.addIssue({
+    code: "custom",
+    message: `custom error`,
+  });
+  return data;
+}, z.string());
+const result = schema.safeParse(1234);
+
+console.dir(result.error!, { depth: null });


Test patch:
diff --git a/packages/zod/src/v4/classic/tests/discriminated-unions.test.ts b/packages/zod/src/v4/classic/tests/discriminated-unions.test.ts
index d0957376..cbc425fc 100644
--- a/packages/zod/src/v4/classic/tests/discriminated-unions.test.ts
+++ b/packages/zod/src/v4/classic/tests/discriminated-unions.test.ts
@@ -156,7 +156,9 @@ test("invalid discriminator value", () => {
             "errors": [],
             "message": "Invalid input",
             "note": "No matching discriminator",
-            "path": [],
+            "path": [
+              "type",
+            ],
           },
         ],
       },