Repository: colinhacks/zod
Pull request #4835 (2025-07-07 15:18:47)

docs: add zod2md to zodToXConverters
Adds my [zod2md](https://github.com/matejchalk/zod2md) library to _Zod to X_ section of _Ecosystem_ docs page. Both Zod v4 and Zod v3 are supported by this library. (The migration approach described in #4371 helped a lot, BTW.)


Patch:
diff --git a/packages/docs/components/ecosystem-v3.tsx b/packages/docs/components/ecosystem-v3.tsx
index a640a3a3..dbd3bae5 100644
--- a/packages/docs/components/ecosystem-v3.tsx
+++ b/packages/docs/components/ecosystem-v3.tsx
@@ -277,6 +277,12 @@ const zodToXConverters: ZodResource[] = [
     description: "(De)serialization for zod schemas",
     slug: "commonbaseapp/zodex",
   },
+  {
+    name: "zod2md",
+    url: "https://github.com/matejchalk/zod2md",
+    description: "Generate Markdown docs from Zod schemas",
+    slug: "matejchalk/zod2md",
+  },
 ];
 
 const xToZodConverters: ZodResource[] = [
diff --git a/packages/docs/components/ecosystem.tsx b/packages/docs/components/ecosystem.tsx
index dc8b539c..1f0f8d6a 100644
--- a/packages/docs/components/ecosystem.tsx
+++ b/packages/docs/components/ecosystem.tsx
@@ -90,7 +90,14 @@ const formIntegrations: ZodResource[] = [
   },
 ];
 
-const zodToXConverters: ZodResource[] = [];
+const zodToXConverters: ZodResource[] = [
+  {
+    name: "zod2md",
+    url: "https://github.com/matejchalk/zod2md",
+    description: "Generate Markdown docs from Zod schemas",
+    slug: "matejchalk/zod2md",
+  },
+];
 
 const xToZodConverters: ZodResource[] = [
   {
diff --git a/packages/docs/content/api.mdx b/packages/docs/content/api.mdx
index 95370272..ac41831d 100644
--- a/packages/docs/content/api.mdx
+++ b/packages/docs/content/api.mdx
@@ -1806,6 +1806,8 @@ const myString = z.string().check(z.refine((val) => val.length <= 255));
   Refinement functions should never throw. Instead they should return a falsy value to signal failure. Thrown errors are not caught by Zod.
 </Callout>
 
+#### `error` 
+
 To customize the error message:
 
 <Tabs groupId="lib" items={["Zod", "Zod Mini"]}>
@@ -1825,6 +1827,7 @@ const myString = z.string().check(
 </Tab>
 </Tabs>
 
+#### `abort` 
 
 By default, validation issues from checks are considered *continuable*; that is, Zod will execute *all* checks in sequence, even if one of them causes a validation error. This is usually desirable, as it means Zod can surface as many errors as possible in one go.
 
@@ -1861,6 +1864,7 @@ result.error.issues;
 </Tab>
 </Tabs>
 
+
 To mark a particular refinement as *non-continuable*, use the `abort` parameter. Validation will terminate if the check fails.
 
 <Tabs groupId="lib" items={["Zod", "Zod Mini"]}>
@@ -1890,6 +1894,8 @@ result.error!.issues;
 </Tab>
 </Tabs>
 
+#### `path`
+
 To customize the error path, use the `path` parameter. This is typically only useful in the context of object schemas.
 
 <Tabs groupId="lib" items={["Zod", "Zod Mini"]}>
@@ -1921,6 +1927,7 @@ const passwordForm = z
 </Tab>
 </Tabs>
 
+
 This will set the `path` parameter in the associated issue:
 
 <Tabs groupId="lib" items={["Zod", "Zod Mini"]}>
@@ -1948,7 +1955,7 @@ result.error.issues;
 </Tab>
 </Tabs>
 
-Refinements can be `async`:
+To define an asynchronous refinement, just pass an `async` function:
 
 ```ts
 const userId = z.string().refine(async (id) => {
@@ -1974,6 +1981,125 @@ const userId = z.string().refine(async (id) => {
   </Tabs>
 </Callout>
 
+#### `when`
+
+> **Note** — This is a power user feature and can absolutely be abused in ways that will increase the probability of uncaught errors originating from inside your refinements. 
+
+By default, refinements don't run if any *non-continuable* issues have already been encountered. Zod is careful to ensure the type signature of the value is correct before passing it into any refinement functions.
+
+```ts
+const schema = z.string().refine((val) => {
+  return val.length > 8
+});
+
+schema.parse(1234); // invalid_type: refinement won't be executed
+```
+
+In some cases, you want finer control over when refinements run. For instance consider this "password confirm" check:
+
+<Tabs groupId="lib" items={["Zod", "Zod Mini"]}>
+<Tab value="Zod">
+```ts
+const schema = z
+  .object({
+    password: z.string().min(8),
+    confirmPassword: z.string(),
+    anotherField: z.string(),
+  })
+  .refine((data) => data.password === data.confirmPassword, {
+    message: "Passwords do not match",
+    path: ["confirmPassword"],
+  });
+
+schema.parse({
+  password: "asdf",
+  confirmPassword: "asdf",
+  anotherField: 1234 // ❌ this error will prevent the password check from running
+});
+```
+</Tab>
+<Tab value="Zod Mini">
+```ts
+const schema = z
+  .object({
+    password: z.string().min(8),
+    confirmPassword: z.string(),
+    anotherField: z.string(),
+  })
+  .check(z.refine((data) => data.password === data.confirmPassword, {
+    message: "Passwords do not match",
+    path: ["confirmPassword"],
+  }));
+
+schema.parse({
+  password: "asdf",
+  confirmPassword: "asdf",
+  anotherField: 1234 // ❌ this error will prevent the password check from running
+});
+```
+</Tab>
+</Tabs>
+
+An error on `anotherField` will prevent the password confirmation check from executing, even though the check doesn't depend on `anotherField`. To control when a refinement will run, use the `when` parameter:
+
+<Tabs groupId="lib" items={["Zod", "Zod Mini"]}>
+<Tab value="Zod">
+```ts
+const schema = z
+  .object({
+    password: z.string().min(8),
+    confirmPassword: z.string(),
+    anotherField: z.string(),
+  })
+  .refine((data) => data.password === data.confirmPassword, {
+    message: "Passwords do not match",
+    path: ["confirmPassword"],
+    when(payload) { // [!code ++]
+      // no issues with `password` or `confirmPassword` // [!code ++]
+      return payload.issues.every((iss) => { // [!code ++]
+        const firstPathEl = iss.path?.[0]; // [!code ++]
+        return firstPathEl !== "password" && firstPathEl !== "confirmPassword"; // [!code ++]
+      }); // [!code ++]
+    },  // [!code ++]
+  });
+
+schema.parse({
+  password: "asdf",
+  confirmPassword: "asdf",
+  anotherField: 1234 // ❌ this error will prevent the password check from running
+});
+```
+</Tab>
+<Tab value="Zod Mini">
+```ts
+const schema = z
+  .object({
+    password: z.string().min(8),
+    confirmPassword: z.string(),
+    anotherField: z.string(),
+  })
+  .check(z.refine((data) => data.password === data.confirmPassword, {
+    message: "Passwords do not match",
+    path: ["confirmPassword"],
+    when(payload) { // [!code ++]
+      // no issues with `password` or `confirmPassword` // [!code ++]
+      return payload.issues.every((iss) => { // [!code ++]
+        const firstPathEl = iss.path?.[0]; // [!code ++]
+        return firstPathEl !== "password" && firstPathEl !== "confirmPassword"; // [!code ++]
+      }); // [!code ++]
+    },  // [!code ++]
+  }));
+
+schema.parse({
+  password: "asdf",
+  confirmPassword: "asdf",
+  anotherField: 1234 // ❌ this error will prevent the password check from running
+});
+```
+</Tab>
+</Tabs>
+
+
 ### `.superRefine()`
 
 <Callout type="warn">
@@ -2009,7 +2135,7 @@ const userId = z.string().refine(async (id) => {
     </Tab>
     <Tab value="Zod Mini">
     ```ts
-    // no equivalent, use `z.check()` instead
+    // no equivalent, use `.check(checkFn)` instead
      
      
      
diff --git a/packages/docs/content/error-formatting.mdx b/packages/docs/content/error-formatting.mdx
index b5836c87..7cc65147 100644
--- a/packages/docs/content/error-formatting.mdx
+++ b/packages/docs/content/error-formatting.mdx
@@ -168,8 +168,6 @@ const flattened = z.flattenError(result.error);
 The `formErrors` array contains any top-level errors (where `path` is `[]`). The `fieldErrors` object provides an array of errors for each field in the schema.
 
 ```ts
-flattened.fieldErrors.string; // => [ 'Invalid input: expected string, received number' ]
-flattened.fieldErrors.numbers; // => [ 'Invalid input: expected number, received string' ]
-```
-
-> **Note** — If you have issues with a `path` longer than one level, this function throws away some error information. 
+flattened.fieldErrors.username; // => [ 'Invalid input: expected string, received number' ]
+flattened.fieldErrors.favoriteNumbers; // => [ 'Invalid input: expected number, received string' ]
+```
\ No newline at end of file
diff --git a/packages/docs/content/index.mdx b/packages/docs/content/index.mdx
index fd37e794..77a43d79 100644
--- a/packages/docs/content/index.mdx
+++ b/packages/docs/content/index.mdx
@@ -167,7 +167,7 @@ Your `"moduleResolution"` should be set to one of the following. The legacy `"no
 
 ## Ecosystem
 
-Zod has a thriving ecosystem of libraries, tools, and integrations. Refer to the [Ecosystem page](/ecososytem) for a complete list of libraries that support Zod or are built on top of it.
+Zod has a thriving ecosystem of libraries, tools, and integrations. Refer to the [Ecosystem page](/ecosystem) for a complete list of libraries that support Zod or are built on top of it.
 
 - [Resources](/ecosystem?id=resources)
 - [API Libraries](/ecosystem?id=api-libraries)
diff --git a/packages/docs/content/json-schema.mdx b/packages/docs/content/json-schema.mdx
index d731a0bd..7fb588f5 100644
--- a/packages/docs/content/json-schema.mdx
+++ b/packages/docs/content/json-schema.mdx
@@ -451,7 +451,7 @@ const jsonSchema = z.toJSONSchema(mySchema, { io: "input" });
 
 Passing a schema into `z.toJSONSchema()` will return a *self-contained* JSON Schema. 
 
-In other cases, you may have a set of Zod schemas you'd like to represent using multiple interlinked JSON Schemas, perhaps to write to `.json` files and serve from a web server. To achieve this, you can pass a [registry](/metadata#registries) into `z.toJSONSchema()`.
+In other cases, you may have a set of Zod schemas you'd like to represent using multiple interlinked JSON Schemas, perhaps to write to `.json` files and serve from a web server. 
 
 ```ts
 import * as z from "zod/v4";
@@ -475,7 +475,9 @@ z.globalRegistry.add(User, {id: "User"});
 z.globalRegistry.add(Post, {id: "Post"});
 ```
 
-The schemas above both have an `id` property registered in `z.globalRegistry`. To convert these to JSON Schema, pass the entire registry into `z.toJSONSchema()`.
+To achieve this, you can pass a [registry](/metadata#registries) into `z.toJSONSchema()`. 
+
+> **Important** — All schemas should have a registered `id` property in the registry! Any schemas without an `id` will be ignored. 
 
 ```ts
 z.toJSONSchema(z.globalRegistry);
@@ -506,9 +508,7 @@ z.toJSONSchema(z.globalRegistry);
 // }
 ```
 
-> Any schema with an `id` property in the registry will be extracted into `schemas`. 
-
-By default, the `$ref` URIs are relative paths like `"User"`. To make these absolute URIs, use the `uri` option. This expects a function that converts an `id` to a fully-qualified URI.
+By default, the `$ref` URIs are simple relative paths like `"User"`. To make these absolute URIs, use the `uri` option. This expects a function that converts an `id` to a fully-qualified URI.
 
 ```ts
 z.toJSONSchema(z.globalRegistry, {
diff --git a/packages/docs/content/metadata.mdx b/packages/docs/content/metadata.mdx
index b5057d15..83538bab 100644
--- a/packages/docs/content/metadata.mdx
+++ b/packages/docs/content/metadata.mdx
@@ -28,6 +28,7 @@ myRegistry.add(mySchema, { description: "A cool schema!"});
 myRegistry.has(mySchema); // => true
 myRegistry.get(mySchema); // => { description: "A cool schema!" }
 myRegistry.remove(mySchema);
+myRegistry.clear(); // wipe registry
 ```
 
 TypeScript enforces that the metadata for each schema matches the registry's **metadata type**.
@@ -37,7 +38,7 @@ myRegistry.add(mySchema, { description: "A cool schema!" }); // ✅
 myRegistry.add(mySchema, { description: 123 }); // ❌
 ```
 
-> **Important** —  If you register metadata on a schema and include an `id` property, Zod will throw an error if multiple schemas share the same `id` value. Make sure all registered `id` values are unique per registry. This also applies to the global registry.
+> **Special handling for `id`** —  Zod registries treat the `id` property specially. An `Error` will be thrown if multiple schemas are registered with the same `id` value. This is true for all registries, including the global registry.
 
 
 ### `.register()`
diff --git a/packages/zod/package.json b/packages/zod/package.json
index 7f5e51f0..eedcfb0f 100644
--- a/packages/zod/package.json
+++ b/packages/zod/package.json
@@ -1,6 +1,6 @@
 {
   "name": "zod",
-  "version": "3.25.75",
+  "version": "3.25.76",
   "type": "module",
   "author": "Colin McDonnell <zod@colinhacks.com>",
   "description": "TypeScript-first schema declaration and validation library with static type inference",
diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts
index b31cb1a4..3d09cb65 100644
--- a/packages/zod/src/v4/classic/schemas.ts
+++ b/packages/zod/src/v4/classic/schemas.ts
@@ -1937,10 +1937,10 @@ export const ZodCustom: core.$constructor<ZodCustom> = /*@__PURE__*/ core.$const
 });
 
 // custom checks
-export function check<O = unknown>(fn: core.CheckFn<O>, params?: string | core.$ZodCustomParams): core.$ZodCheck<O> {
+export function check<O = unknown>(fn: core.CheckFn<O>): core.$ZodCheck<O> {
   const ch = new core.$ZodCheck({
     check: "custom",
-    ...util.normalizeParams(params),
+    // ...util.normalizeParams(params),
   });
 
   ch._zod.check = fn;
@@ -1962,10 +1962,7 @@ export function refine<T>(
 }
 
 // superRefine
-export function superRefine<T>(
-  fn: (arg: T, payload: RefinementCtx<T>) => void | Promise<void>,
-  params?: string | core.$ZodCustomParams
-): core.$ZodCheck<T> {
+export function superRefine<T>(fn: (arg: T, payload: RefinementCtx<T>) => void | Promise<void>): core.$ZodCheck<T> {
   const ch = check<T>((payload) => {
     (payload as RefinementCtx).addIssue = (issue) => {
       if (typeof issue === "string") {
@@ -1983,7 +1980,7 @@ export function superRefine<T>(
     };
 
     return fn(payload.value, payload as RefinementCtx<T>);
-  }, params);
+  });
   return ch;
 }
 
diff --git a/packages/zod/src/v4/core/api.ts b/packages/zod/src/v4/core/api.ts
index b333c9a8..1a487c35 100644
--- a/packages/zod/src/v4/core/api.ts
+++ b/packages/zod/src/v4/core/api.ts
@@ -779,6 +779,8 @@ export function _nan<T extends schemas.$ZodNaN>(Class: util.SchemaClass<T>, para
   });
 }
 
+// export type $ZodCheckParams = CheckParams<checks.$ZodCheck, "abort">;
+
 export type $ZodCheckLessThanParams = CheckParams<checks.$ZodCheckLessThan, "inclusive" | "value">;
 export function _lt(
   value: util.Numeric,
diff --git a/packages/zod/src/v4/core/checks.ts b/packages/zod/src/v4/core/checks.ts
index 42ae75e0..85a7ce7b 100644
--- a/packages/zod/src/v4/core/checks.ts
+++ b/packages/zod/src/v4/core/checks.ts
@@ -13,18 +13,16 @@ export interface $ZodCheckDef {
   error?: errors.$ZodErrorMap<never> | undefined;
   /** If true, no later checks will be executed if this check fails. Default `false`. */
   abort?: boolean | undefined;
+  /** If provided, this check will only be executed if the function returns `true`. Defaults to `payload => z.util.isAborted(payload)`. */
+  when?: ((payload: schemas.ParsePayload) => boolean) | undefined;
 }
 
 export interface $ZodCheckInternals<T> {
   def: $ZodCheckDef;
   /** The set of issues this check might throw. */
   issc?: errors.$ZodIssueBase;
-  // "_check"(input: $ZodResult<T>): util.MaybeAsync<void>;
   check(payload: schemas.ParsePayload<T>): util.MaybeAsync<void>;
-  // _parseB(payload: ParsePayload<any>, ctx: ParseContext): util.MaybeAsync<ParsePayload>;
   onattach: ((schema: schemas.$ZodType) => void)[];
-  // "_async": boolean;
-  when?: ((payload: schemas.ParsePayload) => boolean) | undefined;
 }
 
 export interface $ZodCheck<in T = never> {
@@ -452,7 +450,7 @@ export const $ZodCheckMaxSize: core.$constructor<$ZodCheckMaxSize> = /*@__PURE__
   (inst, def) => {
     $ZodCheck.init(inst, def);
 
-    inst._zod.when = (payload) => {
+    inst._zod.def.when ??= (payload) => {
       const val = payload.value;
       return !util.nullish(val) && (val as any).size !== undefined;
     };
@@ -501,7 +499,7 @@ export const $ZodCheckMinSize: core.$constructor<$ZodCheckMinSize> = /*@__PURE__
   (inst, def) => {
     $ZodCheck.init(inst, def);
 
-    inst._zod.when = (payload) => {
+    inst._zod.def.when ??= (payload) => {
       const val = payload.value;
       return !util.nullish(val) && (val as any).size !== undefined;
     };
@@ -550,7 +548,7 @@ export const $ZodCheckSizeEquals: core.$constructor<$ZodCheckSizeEquals> = /*@__
   (inst, def) => {
     $ZodCheck.init(inst, def);
 
-    inst._zod.when = (payload) => {
+    inst._zod.def.when ??= (payload) => {
       const val = payload.value;
       return !util.nullish(val) && (val as any).size !== undefined;
     };
@@ -604,7 +602,7 @@ export const $ZodCheckMaxLength: core.$constructor<$ZodCheckMaxLength> = /*@__PU
   (inst, def) => {
     $ZodCheck.init(inst, def);
 
-    inst._zod.when = (payload) => {
+    inst._zod.def.when ??= (payload) => {
       const val = payload.value;
       return !util.nullish(val) && (val as any).length !== undefined;
     };
@@ -655,7 +653,7 @@ export const $ZodCheckMinLength: core.$constructor<$ZodCheckMinLength> = /*@__PU
   (inst, def) => {
     $ZodCheck.init(inst, def);
 
-    inst._zod.when = (payload) => {
+    inst._zod.def.when ??= (payload) => {
       const val = payload.value;
       return !util.nullish(val) && (val as any).length !== undefined;
     };
@@ -707,7 +705,7 @@ export const $ZodCheckLengthEquals: core.$constructor<$ZodCheckLengthEquals> = /
   (inst, def) => {
     $ZodCheck.init(inst, def);
 
-    inst._zod.when = (payload) => {
+    inst._zod.def.when ??= (payload) => {
       const val = payload.value;
       return !util.nullish(val) && (val as any).length !== undefined;
     };
diff --git a/packages/zod/src/v4/core/errors.ts b/packages/zod/src/v4/core/errors.ts
index 56ca4e1b..699c81f3 100644
--- a/packages/zod/src/v4/core/errors.ts
+++ b/packages/zod/src/v4/core/errors.ts
@@ -203,6 +203,10 @@ const initializer = (inst: $ZodError, def: $ZodIssue[]): void => {
     enumerable: true,
     // configurable: false,
   });
+  Object.defineProperty(inst, "toString", {
+    value: () => inst.message,
+    enumerable: false,
+  });
 };
 
 export const $ZodError: $constructor<$ZodError> = $constructor("$ZodError", initializer);
diff --git a/packages/zod/src/v4/core/registries.ts b/packages/zod/src/v4/core/registries.ts
index 9c4147d6..14652be8 100644
--- a/packages/zod/src/v4/core/registries.ts
+++ b/packages/zod/src/v4/core/registries.ts
@@ -27,7 +27,7 @@ type MetadataType = Record<string, unknown> | undefined;
 export class $ZodRegistry<Meta extends MetadataType = MetadataType, Schema extends $ZodType = $ZodType> {
   _meta!: Meta;
   _schema!: Schema;
-  _map: WeakMap<Schema, $replace<Meta, Schema>> = new WeakMap();
+  _map: Map<Schema, $replace<Meta, Schema>> = new Map();
   _idmap: Map<string, Schema> = new Map();
 
   add<S extends Schema>(
@@ -45,7 +45,17 @@ export class $ZodRegistry<Meta extends MetadataType = MetadataType, Schema exten
     return this as any;
   }
 
+  clear(): this {
+    this._map = new Map();
+    this._idmap = new Map();
+    return this;
+  }
+
   remove(schema: Schema): this {
+    const meta: any = this._map.get(schema);
+    if (meta && typeof meta === "object" && "id" in meta) {
+      this._idmap.delete(meta.id!);
+    }
     this._map.delete(schema);
     return this;
   }
diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts
index f118cb0f..ce168385 100644
--- a/packages/zod/src/v4/core/schemas.ts
+++ b/packages/zod/src/v4/core/schemas.ts
@@ -204,18 +204,18 @@ export const $ZodType: core.$constructor<$ZodType> = /*@__PURE__*/ core.$constru
       ctx?: ParseContextInternal | undefined
     ): util.MaybeAsync<ParsePayload> => {
       let isAborted = util.aborted(payload);
+
       let asyncResult!: Promise<unknown> | undefined;
       for (const ch of checks) {
-        if (ch._zod.when) {
-          const shouldRun = ch._zod.when(payload);
-
+        if (ch._zod.def.when) {
+          const shouldRun = ch._zod.def.when(payload);
           if (!shouldRun) continue;
         } else if (isAborted) {
           continue;
         }
-
         const currLen = payload.issues.length;
         const _ = ch._zod.check(payload as any) as any as ParsePayload;
+
         if (_ instanceof Promise && ctx?.async === false) {
           throw new core.$ZodAsyncError();
         }
diff --git a/packages/zod/src/v4/core/to-json-schema.ts b/packages/zod/src/v4/core/to-json-schema.ts
index b53b9d16..b2fd5b52 100644
--- a/packages/zod/src/v4/core/to-json-schema.ts
+++ b/packages/zod/src/v4/core/to-json-schema.ts
@@ -47,7 +47,7 @@ interface EmitParams {
     | {
         /**  */
         registry: $ZodRegistry<{ id?: string | undefined }>;
-        uri: (id: string) => string;
+        uri?: ((id: string) => string) | undefined;
         defs: Record<string, JSONSchema.BaseSchema>;
       }
     | undefined;
@@ -614,6 +614,8 @@ export class JSONSchemaGenerator {
     // initialize result with root schema fields
     // Object.assign(result, seen.cached);
 
+    // returns a ref to the schema
+    // defId will be empty if the ref points to an external schema (or #)
     const makeURI = (entry: [schemas.$ZodType<unknown, unknown>, Seen]): { ref: string; defId?: string } => {
       // comparing the seen objects because sometimes
       // multiple schemas map to the same seen object.
@@ -625,12 +627,15 @@ export class JSONSchemaGenerator {
         const externalId = params.external.registry.get(entry[0])?.id; // ?? "__shared";// `__schema${this.counter++}`;
 
         // check if schema is in the external registry
-        if (externalId) return { ref: params.external.uri(externalId) };
+        const uriGenerator = params.external.uri ?? ((id) => id);
+        if (externalId) {
+          return { ref: uriGenerator(externalId) };
+        }
 
         // otherwise, add to __shared
         const id: string = entry[1].defId ?? (entry[1].schema.id as string) ?? `schema${this.counter++}`;
-        entry[1].defId = id;
-        return { defId: id, ref: `${params.external.uri("__shared")}#/${defsSegment}/${id}` };
+        entry[1].defId = id; // set defId so it will be reused if needed
+        return { defId: id, ref: `${uriGenerator("__shared")}#/${defsSegment}/${id}` };
       }
 
       if (entry[1] === root) {
@@ -647,6 +652,7 @@ export class JSONSchemaGenerator {
     // stored cached version in `def` property
     // remove all properties, set $ref
     const extractToDef = (entry: [schemas.$ZodType<unknown, unknown>, Seen]): void => {
+      // if the schema is already a reference, do not extract it
       if (entry[1].schema.$ref) {
         return;
       }
@@ -664,15 +670,29 @@ export class JSONSchemaGenerator {
       schema.$ref = ref;
     };
 
+    // throw on cycles
+
+    // break cycles
+    if (params.cycles === "throw") {
+      for (const entry of this.seen.entries()) {
+        const seen = entry[1];
+        if (seen.cycle) {
+          throw new Error(
+            "Cycle detected: " +
+              `#/${seen.cycle?.join("/")}/<root>` +
+              '\n\nSet the `cycles` parameter to `"ref"` to resolve cyclical schemas with defs.'
+          );
+        }
+      }
+    }
+
     // extract schemas into $defs
     for (const entry of this.seen.entries()) {
       const seen = entry[1];
 
       // convert root schema to # $ref
-      // also prevents root schema from being extracted
       if (schema === entry[0]) {
-        // do not copy to defs...this is the root schema
-        extractToDef(entry);
+        extractToDef(entry); // this has special handling for the root schema
         continue;
       }
 
@@ -689,21 +709,13 @@ export class JSONSchemaGenerator {
       const id = this.metadataRegistry.get(entry[0])?.id;
       if (id) {
         extractToDef(entry);
-
         continue;
       }
 
       // break cycles
       if (seen.cycle) {
-        if (params.cycles === "throw") {
-          throw new Error(
-            "Cycle detected: " +
-              `#/${seen.cycle?.join("/")}/<root>` +
-              '\n\nSet the `cycles` parameter to `"ref"` to resolve cyclical schemas with defs.'
-          );
-        } else if (params.cycles === "ref") {
-          extractToDef(entry);
-        }
+        // any
+        extractToDef(entry);
         continue;
       }
 
@@ -768,6 +780,12 @@ export class JSONSchemaGenerator {
       console.warn(`Invalid target: ${this.target}`);
     }
 
+    if (params.external?.uri) {
+      const id = params.external.registry.get(schema)?.id;
+      if (!id) throw new Error("Schema is missing an `id` property");
+      result.$id = params.external.uri(id);
+    }
+
     Object.assign(result, root.def);
 
     // build defs object
@@ -780,11 +798,14 @@ export class JSONSchemaGenerator {
     }
 
     // set definitions in result
-    if (!params.external && Object.keys(defs).length > 0) {
-      if (this.target === "draft-2020-12") {
-        result.$defs = defs;
-      } else {
-        result.definitions = defs;
+    if (params.external) {
+    } else {
+      if (Object.keys(defs).length > 0) {
+        if (this.target === "draft-2020-12") {
+          result.$defs = defs;
+        } else {
+          result.definitions = defs;
+        }
       }
     }
 
@@ -824,7 +845,7 @@ export function toJSONSchema(
     const schemas: Record<string, JSONSchema.BaseSchema> = {};
     const external = {
       registry: input,
-      uri: (_params as RegistryToJSONSchemaParams)?.uri || ((id) => id),
+      uri: (_params as RegistryToJSONSchemaParams)?.uri,
       defs,
     };
     for (const entry of input._idmap.entries()) {
diff --git a/play.ts b/play.ts
index 2d599038..fe853f11 100644
--- a/play.ts
+++ b/play.ts
@@ -1,44 +1,31 @@
-import { z } from "zod/v4";
+import * as z from "zod/v4";
 
-z;
+// const category = z.object({
+//   name: z.string(),
+//   get subcategories() {
+//     return z.array(category);
+//   },
+// });
 
-// import * as z from "zod/v4";
+// console.dir(
+//   z.toJSONSchema(z.object({ outer: category }), {
+//     cycles: "throw",
+//   }),
+//   { depth: null }
+// );
 
-export type SomeRecursiveUnion1 = z.ZodUnion<
-  [
-    z.ZodObject<{
-      foo: z.ZodReadonly<z.ZodPrefault<SomeRecursiveUnion1>>;
-    }>,
-  ]
->;
-
-// type Foo = z.core.$ZodRecord<z.ZodString, B>;
-// type B = z.ZodPipe<B, Foo>;
-
-const Category = z.object({
+const A = z.object({
   name: z.string(),
   get subcategories() {
-    return z.array(z.union([z.string(), Category]));
+    return z.array(B);
   },
 });
 
-const err = new z.core.$ZodError([
-  {
-    code: "invalid_type",
-    expected: "string",
-    path: [],
-    message: "Expected string, received number",
-    input: 1,
+const B = z.object({
+  name: z.string(),
+  get subcategories() {
+    return z.array(A);
   },
-]);
-console.log(err.toString());
-// But it works without `z.ZodReadonly` or without recursive reference:
+});
 
-// export type SomeRecursiveUnion2 = z.ZodUnion<
-//   [
-//     z.ZodObject<{
-//       foo: z.ZodRecord<z.ZodString, SomeRecursiveUnion2>;
-//       bar: z.ZodReadonly<z.ZodRecord<z.ZodString, z.ZodString>>;
-//     }>,
-//   ]
-// >;
+console.dir(z.toJSONSchema(A, { cycles: "throw" }), { depth: null });


Test patch:
diff --git a/packages/zod/src/v4/classic/tests/recursive-types.test.ts b/packages/zod/src/v4/classic/tests/recursive-types.test.ts
index f3b7586e..ce320e4b 100644
--- a/packages/zod/src/v4/classic/tests/recursive-types.test.ts
+++ b/packages/zod/src/v4/classic/tests/recursive-types.test.ts
@@ -324,3 +324,33 @@ test("recursion compatibility", () => {
     },
   });
 });
+
+// biome-ignore lint: sadf
+export type RecursiveA = z.ZodUnion<
+  [
+    z.ZodObject<{
+      a: z.ZodDefault<RecursiveA>;
+      b: z.ZodPrefault<RecursiveA>;
+      c: z.ZodNonOptional<RecursiveA>;
+      d: z.ZodOptional<RecursiveA>;
+      e: z.ZodNullable<RecursiveA>;
+      g: z.ZodReadonly<RecursiveA>;
+      h: z.ZodPipe<RecursiveA, z.ZodString>;
+      i: z.ZodArray<RecursiveA>;
+      j: z.ZodSet<RecursiveA>;
+      k: z.ZodMap<RecursiveA, RecursiveA>;
+      l: z.ZodRecord<z.ZodString, RecursiveA>;
+      m: z.ZodUnion<[RecursiveA, RecursiveA]>;
+      n: z.ZodIntersection<RecursiveA, RecursiveA>;
+      o: z.ZodLazy<RecursiveA>;
+      p: z.ZodPromise<RecursiveA>;
+      q: z.ZodCatch<RecursiveA>;
+      r: z.ZodSuccess<RecursiveA>;
+      s: z.ZodTransform<RecursiveA, string>;
+      t: z.ZodTuple<[RecursiveA, RecursiveA]>;
+      u: z.ZodObject<{
+        a: RecursiveA;
+      }>;
+    }>,
+  ]
+>;
diff --git a/packages/zod/src/v4/classic/tests/refine.test.ts b/packages/zod/src/v4/classic/tests/refine.test.ts
index df5d6b9d..a528b2a7 100644
--- a/packages/zod/src/v4/classic/tests/refine.test.ts
+++ b/packages/zod/src/v4/classic/tests/refine.test.ts
@@ -421,3 +421,112 @@ describe("type refinement", () => {
   });
 });
 */
+
+test("when", () => {
+  const schema = z
+    .strictObject({
+      password: z.string().min(8),
+      confirmPassword: z.string(),
+      other: z.string(),
+    })
+    .refine(
+      (data) => {
+        console.log("running check...");
+        console.log(data);
+        console.log(data.password);
+        return data.password === data.confirmPassword;
+      },
+      {
+        message: "Passwords do not match",
+        path: ["confirmPassword"],
+        when(payload) {
+          if (payload.value === undefined) return false;
+          if (payload.value === null) return false;
+          // no issues with confirmPassword or password
+          return payload.issues.every((iss) => iss.path?.[0] !== "confirmPassword" && iss.path?.[0] !== "password");
+        },
+      }
+    );
+
+  expect(schema.safeParse(undefined)).toMatchInlineSnapshot(`
+    {
+      "error": [ZodError: [
+      {
+        "expected": "object",
+        "code": "invalid_type",
+        "path": [],
+        "message": "Invalid input: expected object, received undefined"
+      }
+    ]],
+      "success": false,
+    }
+  `);
+  expect(schema.safeParse(null)).toMatchInlineSnapshot(`
+    {
+      "error": [ZodError: [
+      {
+        "expected": "object",
+        "code": "invalid_type",
+        "path": [],
+        "message": "Invalid input: expected object, received null"
+      }
+    ]],
+      "success": false,
+    }
+  `);
+  expect(
+    schema.safeParse({
+      password: "asdf",
+      confirmPassword: "asdfg",
+      other: "qwer",
+    })
+  ).toMatchInlineSnapshot(`
+    {
+      "error": [ZodError: [
+      {
+        "origin": "string",
+        "code": "too_small",
+        "minimum": 8,
+        "inclusive": true,
+        "path": [
+          "password"
+        ],
+        "message": "Too small: expected string to have >=8 characters"
+      }
+    ]],
+      "success": false,
+    }
+  `);
+
+  expect(
+    schema.safeParse({
+      password: "asdf",
+      confirmPassword: "asdfg",
+      other: 1234,
+    })
+  ).toMatchInlineSnapshot(`
+    {
+      "error": [ZodError: [
+      {
+        "origin": "string",
+        "code": "too_small",
+        "minimum": 8,
+        "inclusive": true,
+        "path": [
+          "password"
+        ],
+        "message": "Too small: expected string to have >=8 characters"
+      },
+      {
+        "expected": "string",
+        "code": "invalid_type",
+        "path": [
+          "other"
+        ],
+        "message": "Invalid input: expected string, received number"
+      }
+    ]],
+      "success": false,
+    }
+  `);
+});
diff --git a/packages/zod/src/v4/classic/tests/registries.test.ts b/packages/zod/src/v4/classic/tests/registries.test.ts
index 23209ad7..213d9d8d 100644
--- a/packages/zod/src/v4/classic/tests/registries.test.ts
+++ b/packages/zod/src/v4/classic/tests/registries.test.ts
@@ -193,3 +193,12 @@ test("function meta with replacement", () => {
 
   expect(myReg.get(mySchema)!.defaulter("hello", true)).toEqual(5);
 });
+
+test("test .clear()", () => {
+  const reg = z.registry();
+  const a = z.string();
+  reg.add(a);
+  expect(reg.has(a)).toEqual(true);
+  reg.clear();
+  expect(reg.has(a)).toEqual(false);
+});
diff --git a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts
index cfcd53ea..87834d87 100644
--- a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts
+++ b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts
@@ -1746,16 +1746,17 @@ test("basic registry", () => {
   myRegistry.add(User, { id: "User" });
   myRegistry.add(Post, { id: "Post" });
 
-  const result = z.z.toJSONSchema(myRegistry);
+  const result = z.z.toJSONSchema(myRegistry, { uri: (id) => `https://example.com/${id}.json` });
   expect(result).toMatchInlineSnapshot(`
     {
       "schemas": {
         "Post": {
+          "$id": "https://example.com/Post.json",
           "$schema": "https://json-schema.org/draft/2020-12/schema",
           "additionalProperties": false,
           "properties": {
             "author": {
-              "$ref": "User",
+              "$ref": "https://example.com/User.json",
             },
             "content": {
               "type": "string",
@@ -1772,6 +1773,7 @@ test("basic registry", () => {
           "type": "object",
         },
         "User": {
+          "$id": "https://example.com/User.json",
           "$schema": "https://json-schema.org/draft/2020-12/schema",
           "additionalProperties": false,
           "properties": {
@@ -1780,7 +1782,7 @@ test("basic registry", () => {
             },
             "posts": {
               "items": {
-                "$ref": "Post",
+                "$ref": "https://example.com/Post.json",
               },
               "type": "array",
             },
@@ -2273,3 +2275,40 @@ test("custom toJSONSchema", () => {
     }
   `);
 });
+
+test("cycle detection - root", () => {
+  const schema = z.object({
+    name: z.string(),
+    get subcategories() {
+      return z.array(schema);
+    },
+  });
+
+  expect(() => z.toJSONSchema(schema, { cycles: "throw" })).toThrowErrorMatchingInlineSnapshot(`
+    [Error: Cycle detected: #/properties/subcategories/items/<root>
+
+    Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.]
+  `);
+});
+
+test("cycle detection - mutual recursion", () => {
+  const A = z.object({
+    name: z.string(),
+    get subcategories() {
+      return z.array(B);
+    },
+  });
+
+  const B = z.object({
+    name: z.string(),
+    get subcategories() {
+      return z.array(A);
+    },
+  });
+
+  expect(() => z.toJSONSchema(A, { cycles: "throw" })).toThrowErrorMatchingInlineSnapshot(`
+    [Error: Cycle detected: #/properties/subcategories/items/properties/subcategories/items/<root>
+
+    Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.]
+  `);
+});