Repository: colinhacks/zod
Pull request #4401 (2025-05-18 20:44:50)

Support metadata inheritance across clones


Patch:
diff --git a/README.md b/README.md
index 6b3ffa64..939c232a 100644
--- a/README.md
+++ b/README.md
@@ -2158,7 +2158,6 @@ await stringSchema.parseAsync("hello world"); // => throws error
 
 ### `.safeParse`
 
-`.safeParse(data:unknown): { success: true; data: T; } | { success: false; error: ZodError; }`
 
 If you don't want Zod to throw errors when validation fails, use `.safeParse`. This method returns an object containing either the successfully parsed data or a ZodError instance containing detailed information about the validation problems.
 
diff --git a/README_ZH.md b/README_ZH.md
index 5205e572..b955341a 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -1473,7 +1473,6 @@ const value = await stringSchema.parseAsync("hello"); // => hello
 
 ### `.safeParse`
 
-`.safeParse(data:unknown): { success: true; data: T; } | { success: false; error: ZodError; }`
 
 如果你不希望 Zod 在验证失败时抛出错误，请使用`.safeParse`。该方法返回一个包含成功解析的数据的对象，或者一个包含验证问题详细信息的 ZodError 实例。
 
diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts
index a38c7c2f..5e9dcea6 100644
--- a/packages/zod/src/v4/classic/schemas.ts
+++ b/packages/zod/src/v4/classic/schemas.ts
@@ -30,7 +30,7 @@ export interface ZodType<out Output = unknown, out Input = unknown> extends core
   _input: core.input<this>;
   // base methods
   check(...checks: (core.CheckFn<core.output<this>> | core.$ZodCheck<core.output<this>>)[]): this;
-  clone(def?: this["_zod"]["def"]): this;
+  clone(def?: this["_zod"]["def"], params?: { parent: boolean }): this;
   register<R extends core.$ZodRegistry>(
     registry: R,
     ...meta: this extends R["_schema"]
@@ -120,17 +120,20 @@ export const ZodType: core.$constructor<ZodType> = /*@__PURE__*/ core.$construct
 
   // base methods
   inst.check = (...checks) => {
-    return inst.clone({
-      ...def,
-      checks: [
-        ...(def.checks ?? []),
-        ...checks.map((ch) =>
-          typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch
-        ),
-      ],
-    });
+    return inst.clone(
+      {
+        ...def,
+        checks: [
+          ...(def.checks ?? []),
+          ...checks.map((ch) =>
+            typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch
+          ),
+        ],
+      }
+      // { parent: true }
+    );
   };
-  inst.clone = (_def) => core.clone(inst, _def);
+  inst.clone = (def, params) => core.clone(inst, def, params);
   inst.brand = () => inst as any;
   inst.register = ((reg: any, meta: any) => {
     reg.add(inst, meta);
@@ -192,9 +195,7 @@ export const ZodType: core.$constructor<ZodType> = /*@__PURE__*/ core.$construct
   // meta
   inst.describe = (description) => {
     const cl = inst.clone();
-    const meta = { ...(core.globalRegistry.get(inst) ?? {}), description };
-    delete meta.id; // do not inherit
-    core.globalRegistry.add(cl, meta);
+    core.globalRegistry.add(cl, { description });
     return cl;
   };
   Object.defineProperty(inst, "description", {
@@ -204,7 +205,9 @@ export const ZodType: core.$constructor<ZodType> = /*@__PURE__*/ core.$construct
     configurable: true,
   });
   inst.meta = (...args: any) => {
-    if (args.length === 0) return core.globalRegistry.get(inst);
+    if (args.length === 0) {
+      return core.globalRegistry.get(inst);
+    }
     const cl = inst.clone();
     core.globalRegistry.add(cl, args[0]);
     return cl as any;
diff --git a/packages/zod/src/v4/core/registries.ts b/packages/zod/src/v4/core/registries.ts
index 28c0eaaf..b11a5bce 100644
--- a/packages/zod/src/v4/core/registries.ts
+++ b/packages/zod/src/v4/core/registries.ts
@@ -45,6 +45,15 @@ export class $ZodRegistry<Meta extends MetadataType = MetadataType, Schema exten
   }
 
   get<S extends Schema>(schema: S): $replace<Meta, S> | undefined {
+    // return this._map.get(schema) as any;
+
+    // inherit metadata
+    const p = schema._zod.parent as Schema;
+    if (p) {
+      const pm: any = { ...(this.get(p) ?? {}) };
+      delete pm.id; // do not inherit id
+      return { ...pm, ...this._map.get(schema) } as any;
+    }
     return this._map.get(schema) as any;
   }
 
diff --git a/packages/zod/src/v4/core/to-json-schema.ts b/packages/zod/src/v4/core/to-json-schema.ts
index ba3686ea..8d9effaf 100644
--- a/packages/zod/src/v4/core/to-json-schema.ts
+++ b/packages/zod/src/v4/core/to-json-schema.ts
@@ -130,11 +130,19 @@ export class JSONSchemaGenerator {
       path: _params.path,
     };
 
-    if (schema._zod.parent) {
+    const parent = schema._zod.parent;
+    // if (parent) {
+    //   // schema was cloned from another schema
+    //   result.ref = parent;
+    //   this.process(parent, params);
+    //   this.seen.get(parent)!.isParent = true;
+    // }
+
+    if (parent) {
       // schema was cloned from another schema
-      result.ref = schema._zod.parent;
-      this.process(schema._zod.parent, params);
-      this.seen.get(schema._zod.parent)!.isParent = true;
+      result.ref = parent;
+      this.process(parent, params);
+      this.seen.get(parent)!.isParent = true;
     } else {
       const _json = result.schema;
       switch (def.type) {
diff --git a/packages/zod/src/v4/core/util.ts b/packages/zod/src/v4/core/util.ts
index b666d612..d235fdb1 100644
--- a/packages/zod/src/v4/core/util.ts
+++ b/packages/zod/src/v4/core/util.ts
@@ -406,9 +406,9 @@ export function escapeRegex(str: string): string {
 }
 
 // zod-specific utils
-export function clone<T extends schemas.$ZodType>(inst: T, def?: T["_zod"]["def"]): T {
+export function clone<T extends schemas.$ZodType>(inst: T, def?: T["_zod"]["def"], params?: { parent: boolean }): T {
   const cl = new inst._zod.constr(def ?? inst._zod.def);
-  if (!def) cl._zod.parent = inst;
+  if (!def || params?.parent) cl._zod.parent = inst;
   return cl as any;
 }
 
diff --git a/packages/zod/src/v4/mini/schemas.ts b/packages/zod/src/v4/mini/schemas.ts
index b720a717..f8cb8a4f 100644
--- a/packages/zod/src/v4/mini/schemas.ts
+++ b/packages/zod/src/v4/mini/schemas.ts
@@ -9,7 +9,7 @@ type SomeType = core.$ZodType;
 
 export interface ZodMiniType<out Output = unknown, out Input = unknown> extends core.$ZodType<Output, Input> {
   check(...checks: (core.CheckFn<this["_zod"]["output"]> | core.$ZodCheck<this["_zod"]["output"]>)[]): this;
-  clone(def?: this["_zod"]["def"]): this;
+  clone(def?: this["_zod"]["def"], params?: { parent: boolean }): this;
   register<R extends core.$ZodRegistry>(
     registry: R,
     ...meta: this extends R["_schema"]
@@ -45,17 +45,20 @@ export const ZodMiniType: core.$constructor<ZodMiniType> = /*@__PURE__*/ core.$c
     inst.parseAsync = async (data, params) => parse.parseAsync(inst, data, params, { callee: inst.parseAsync });
     inst.safeParseAsync = async (data, params) => parse.safeParseAsync(inst, data, params);
     inst.check = (...checks) => {
-      return inst.clone({
-        ...def,
-        checks: [
-          ...(def.checks ?? []),
-          ...checks.map((ch) =>
-            typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch
-          ),
-        ],
-      });
+      return inst.clone(
+        {
+          ...def,
+          checks: [
+            ...(def.checks ?? []),
+            ...checks.map((ch) =>
+              typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch
+            ),
+          ],
+        }
+        // { parent: true }
+      );
     };
-    inst.clone = (_def) => core.clone(inst, _def);
+    inst.clone = (_def, params) => core.clone(inst, _def, params);
     inst.brand = () => inst as any;
     inst.register = ((reg: any, meta: any) => {
       reg.add(inst, meta);
diff --git a/play.ts b/play.ts
index ea548415..54caaf7c 100644
--- a/play.ts
+++ b/play.ts
@@ -1,20 +1,9 @@
 import * as z from "zod/v4";
 
-const schema = z.object({
-  a: z.literal("discKey"),
-  b: z.enum(["apple", "banana"]),
-  c: z.object({ id: z.string() }),
-});
-
-const input = {
-  a: "discKey",
-  b: "apple",
-  c: {}, // Invalid, as schema requires `id` property
-};
-
-// Validation must fail here, but it doesn't
-const testDiscUnion = z.discriminatedUnion([schema]);
-// .parse(input);
-// console.dir(testDiscUnion, { depth: null });
-
-console.dir(testDiscUnion.options, { depth: null });
+const A = z.string().meta({ a: true });
+console.dir(A.meta(), { depth: null });
+const B = A.meta({ b: true });
+console.dir(B.meta(), { depth: null });
+const C = B.describe("hello");
+console.dir(C.meta(), { depth: null });
+// console.dir(C._zod.parent._zodreg, { depth: null });


Test patch:
diff --git a/packages/zod/src/v4/classic/tests/registries.test.ts b/packages/zod/src/v4/classic/tests/registries.test.ts
index bc353abb..2b93f0ae 100644
--- a/packages/zod/src/v4/classic/tests/registries.test.ts
+++ b/packages/zod/src/v4/classic/tests/registries.test.ts
@@ -148,3 +148,12 @@ test(".describe", () => {
   expect(a1.description).toEqual(undefined);
   expect(a2.description).toEqual("Hello");
 });
+
+test("inherit across clone", () => {
+  const A = z.string().meta({ a: true });
+  expect(A.meta()).toEqual({ a: true });
+  const B = A.meta({ b: true });
+  expect(B.meta()).toEqual({ a: true, b: true });
+  const C = B.describe("hello");
+  expect(C.meta()).toEqual({ a: true, b: true, description: "hello" });
+});