zod 4.5.2 → 4.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zod",
3
- "version": "4.5.2",
3
+ "version": "4.5.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "Colin McDonnell <zod@colinhacks.com>",
@@ -1149,7 +1149,7 @@ describe("toJSONSchema", () => {
1149
1149
  ]);
1150
1150
  });
1151
1151
 
1152
- test("record filters enum values to strings and numbers for required", () => {
1152
+ test("record stringifies numeric enum keys for propertyNames and required", () => {
1153
1153
  enum NumberEnum {
1154
1154
  Zero = 0,
1155
1155
  One = 1,
@@ -1164,20 +1164,124 @@ describe("toJSONSchema", () => {
1164
1164
  },
1165
1165
  "propertyNames": {
1166
1166
  "enum": [
1167
- 0,
1168
- 1,
1167
+ "0",
1168
+ "1",
1169
1169
  ],
1170
- "type": "number",
1170
+ "type": "string",
1171
1171
  },
1172
1172
  "required": [
1173
- 0,
1174
- 1,
1173
+ "0",
1174
+ "1",
1175
1175
  ],
1176
1176
  "type": "object",
1177
1177
  }
1178
1178
  `);
1179
1179
  });
1180
1180
 
1181
+ test("record with a numeric key emits propertyNames over the numeric-string form", () => {
1182
+ expect(z.toJSONSchema(z.record(z.number(), z.boolean())).propertyNames).toEqual({
1183
+ type: "string",
1184
+ pattern: "^-?\\d+(?:\\.\\d+)?$",
1185
+ });
1186
+ // range checks can't apply to a key, so only the integer shape survives
1187
+ expect(z.toJSONSchema(z.record(z.int32(), z.boolean())).propertyNames).toEqual({
1188
+ type: "string",
1189
+ pattern: "^-?\\d+$",
1190
+ });
1191
+ expect(z.toJSONSchema(z.record(z.literal([1, 2]), z.boolean()))).toMatchObject({
1192
+ propertyNames: { type: "string", enum: ["1", "2"] },
1193
+ required: ["1", "2"],
1194
+ });
1195
+ expect(z.toJSONSchema(z.record(z.literal(1), z.boolean())).propertyNames).toEqual({
1196
+ type: "string",
1197
+ const: "1",
1198
+ });
1199
+ });
1200
+
1201
+ test("record key rewrite reaches through wrappers and union branches", () => {
1202
+ const numericString = { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" };
1203
+ // a wrapper only carries its inner type once the refs are flattened, so this is decided after the record itself is emitted
1204
+ expect(
1205
+ z.toJSONSchema(
1206
+ z.record(
1207
+ z.lazy(() => z.number()),
1208
+ z.boolean()
1209
+ )
1210
+ ).propertyNames
1211
+ ).toEqual(numericString);
1212
+ expect(z.toJSONSchema(z.record(z.number().pipe(z.number()), z.boolean())).propertyNames).toEqual(numericString);
1213
+ expect(z.toJSONSchema(z.record(z.number().readonly(), z.boolean())).propertyNames).toMatchObject(numericString);
1214
+ expect(z.toJSONSchema(z.record(z.union([z.literal("Tuna"), z.literal(21)]), z.string()))).toMatchObject({
1215
+ propertyNames: {
1216
+ anyOf: [
1217
+ { type: "string", const: "Tuna" },
1218
+ { type: "string", const: "21" },
1219
+ ],
1220
+ },
1221
+ required: ["Tuna", "21"],
1222
+ });
1223
+ });
1224
+
1225
+ test("record with a numeric key inlines an extracted key, and leaves a string one referenced", () => {
1226
+ expect(z.toJSONSchema(z.record(z.number().meta({ id: "Num" }), z.boolean())).propertyNames).toEqual({
1227
+ type: "string",
1228
+ pattern: "^-?\\d+(?:\\.\\d+)?$",
1229
+ });
1230
+ expect(z.toJSONSchema(z.record(z.string().meta({ id: "Str" }), z.boolean())).propertyNames).toEqual({
1231
+ $ref: "#/$defs/Str",
1232
+ });
1233
+ // the value position still wants the number form, so the two cannot share one def
1234
+ const key = z.number().meta({ id: "Shared" });
1235
+ expect(z.toJSONSchema(z.object({ a: key, b: z.record(key, z.string()) }))).toMatchObject({
1236
+ properties: { a: { $ref: "#/$defs/Shared" }, b: { propertyNames: { type: "string" } } },
1237
+ $defs: { Shared: { type: "number" } },
1238
+ });
1239
+ });
1240
+
1241
+ test("record key rewrite reaches a wrapped record", () => {
1242
+ // the flatten copies a record's properties onto its wrapper by reference, so the rewrite has to find every copy
1243
+ expect(z.toJSONSchema(z.record(z.number(), z.boolean()).optional()).propertyNames).toEqual({
1244
+ type: "string",
1245
+ pattern: "^-?\\d+(?:\\.\\d+)?$",
1246
+ });
1247
+ expect(z.toJSONSchema(z.object({ a: z.record(z.literal([1, 2]), z.boolean()).optional() }))).toMatchObject({
1248
+ properties: { a: { propertyNames: { type: "string", enum: ["1", "2"] }, required: ["1", "2"] } },
1249
+ });
1250
+ });
1251
+
1252
+ test("record with a recursive key converts without looping", () => {
1253
+ const numeric: any = z.lazy(() => z.union([z.number(), numeric]));
1254
+ expect(z.toJSONSchema(z.record(numeric, z.boolean())).propertyNames).toMatchObject({
1255
+ anyOf: [{ type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" }, { $ref: "#/$defs/__schema0" }],
1256
+ });
1257
+ // a key with nothing to re-express keeps the reference it had
1258
+ const stringy: any = z.lazy(() => z.union([z.string(), stringy]));
1259
+ expect(z.toJSONSchema(z.record(stringy, z.boolean())).propertyNames).toEqual({ $ref: "#/$defs/__schema0" });
1260
+ expect(
1261
+ z.toJSONSchema(z.record(z.union([z.literal("a"), z.literal("b")]).meta({ id: "Keys" }), z.boolean()))
1262
+ .propertyNames
1263
+ ).toEqual({ $ref: "#/$defs/Keys" });
1264
+ });
1265
+
1266
+ test("record with a heterogeneous key stringifies only its numeric members", () => {
1267
+ // a mixed key carries no `type`, so the numeric members are caught by value rather than by type
1268
+ expect(z.toJSONSchema(z.record(z.literal(["a", 1]), z.boolean()))).toMatchObject({
1269
+ propertyNames: { enum: ["a", "1"] },
1270
+ required: ["a", "1"],
1271
+ });
1272
+ // a member no key can spell is left as it was, since the parser only ever retries a key as a number
1273
+ expect(z.toJSONSchema(z.record(z.literal(["a", true]) as any, z.boolean())).propertyNames).toEqual({
1274
+ enum: ["a", true],
1275
+ });
1276
+ });
1277
+
1278
+ test("record stringifies required for every target", () => {
1279
+ const schema = z.record(z.literal([1, 2]), z.boolean());
1280
+ for (const target of ["draft-2020-12", "draft-7", "draft-4", "openapi-3.0"] as const) {
1281
+ expect(z.toJSONSchema(schema, { target }).required).toEqual(["1", "2"]);
1282
+ }
1283
+ });
1284
+
1181
1285
  test("strict record with regex key uses propertyNames", () => {
1182
1286
  const schema = z.record(z.string().regex(/^label:[a-z]{2}$/), z.string());
1183
1287
 
@@ -3022,6 +3126,34 @@ test("large registry converts in linear time", () => {
3022
3126
  expect(folded.elapsed).toBeLessThan(plain.elapsed * 4 + 100);
3023
3127
  });
3024
3128
 
3129
+ test("a registry of records with numeric keys converts in linear time", () => {
3130
+ const count = 2000;
3131
+ const convert = (key: (i: number) => z.core.$ZodType) => {
3132
+ const registry = z.registry<{ id: string }>();
3133
+ for (let i = 0; i < count; i++) {
3134
+ registry.add(z.object({ m: z.record(key(i) as z.core.$ZodRecordKey, z.boolean()) }), { id: `Type${i}` });
3135
+ }
3136
+ const start = performance.now();
3137
+ const { schemas } = z.toJSONSchema(registry, { uri: (id) => `https://example.com/${id}.json` });
3138
+ return { schemas, elapsed: performance.now() - start };
3139
+ };
3140
+
3141
+ const string = convert(() => z.string());
3142
+ const numeric = convert(() => z.number());
3143
+ expect(numeric.schemas.Type0).toMatchObject({
3144
+ properties: { m: { propertyNames: { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" } } },
3145
+ });
3146
+
3147
+ // The rewrite has to find every carrier the flatten copied `propertyNames` onto, which means a pass over the whole seen map. Running that once per record rather than once per conversion cost ~10x at this size. A string key needs no rewrite at all, so comparing against it keeps this independent of how fast the machine is.
3148
+ expect(numeric.elapsed).toBeLessThan(string.elapsed * 2 + 50);
3149
+
3150
+ // An extracted key resolves through a map built once per conversion rather than a search per reference. There is no stable timing control for that — extraction has its own $defs cost, which swamps the difference — so this only pins the shape.
3151
+ const extracted = convert((i) => z.number().meta({ id: `Key${i}` }));
3152
+ expect(extracted.schemas.Type0).toMatchObject({
3153
+ properties: { m: { propertyNames: { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" } } },
3154
+ });
3155
+ });
3156
+
3025
3157
  test("registry extracts unregistered subschemas into __shared", () => {
3026
3158
  const registry = z.registry<{ id: string }>();
3027
3159
  const address = z.object({ street: z.string() }).meta({ id: "Address" });
@@ -1,11 +1,13 @@
1
1
  import type * as checks from "./checks.js";
2
2
  import type * as JSONSchema from "./json-schema.js";
3
+ import * as regexes from "./regexes.js";
3
4
  import type { $ZodRegistry } from "./registries.js";
4
5
  import type * as schemas from "./schemas.js";
5
6
  import {
6
7
  type ProcessParams,
7
8
  type Processor,
8
9
  type RegistryToJSONSchemaParams,
10
+ type Seen,
9
11
  type ToJSONSchemaContext,
10
12
  type ToJSONSchemaParams,
11
13
  type ZodStandardJSONSchemaPayload,
@@ -46,11 +48,11 @@ export const stringProcessor: Processor<schemas.$ZodString> = (schema, ctx, _jso
46
48
  }
47
49
  if (contentEncoding) json.contentEncoding = contentEncoding;
48
50
  if (patterns && patterns.size > 0) {
49
- const regexes = [...patterns];
50
- if (regexes.length === 1) json.pattern = regexes[0]!.source;
51
- else if (regexes.length > 1) {
51
+ const patternList = [...patterns];
52
+ if (patternList.length === 1) json.pattern = patternList[0]!.source;
53
+ else if (patternList.length > 1) {
52
54
  json.allOf = [
53
- ...regexes.map((regex) => ({
55
+ ...patternList.map((regex) => ({
54
56
  ...(ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0"
55
57
  ? ({ type: "string" } as const)
56
58
  : {}),
@@ -475,6 +477,85 @@ export const tupleProcessor: Processor<schemas.$ZodTuple> = (schema, ctx, _json,
475
477
  if (typeof maximum === "number") json.maxItems = maximum;
476
478
  };
477
479
 
480
+ /** JSON object keys are always strings, so a numeric record key schema is re-expressed over the
481
+ * numeric-string form the record parser matches. Deferred to `finalize`, after the flatten: a key
482
+ * behind a wrapper only carries its own `type` before then, and a union key only has its branches.
483
+ *
484
+ * A numeric bound cannot apply to a property name, so `minimum` and its siblings are dropped rather
485
+ * than carried over: keeping them beside `type: "string"` reproduces the match-nothing schema this
486
+ * exists to fix. A key that carries one therefore emits wider than the record parses — `z.record(z.number().min(5), V)`
487
+ * accepts `"3"` — which is the deliberate trade, since throwing on it would reject an ordinary schema
488
+ * outright. */
489
+ function stringifyKeyNames(
490
+ bySchema: Map<JSONSchema.BaseSchema, Seen>,
491
+ json: JSONSchema.BaseSchema,
492
+ visited: Set<JSONSchema.BaseSchema>
493
+ ): JSONSchema.BaseSchema {
494
+ // an extracted key that rewrites cannot go on sharing its definition — the string form a key position needs is not the number form every other reference wants — so it inlines. One that does not rewrite keeps the `$ref`.
495
+ if (json.$ref) {
496
+ // a recursive key holds its own reference inside its definition, so a node already on the path is left alone rather than resolved again
497
+ if (visited.has(json)) return json;
498
+ visited.add(json);
499
+ const def = bySchema.get(json)?.def;
500
+ if (!def) return json;
501
+ const inlined = stringifyKeyNames(bySchema, def, visited);
502
+ return inlined === def ? json : inlined;
503
+ }
504
+
505
+ for (const keyword of ["anyOf", "oneOf"] as const) {
506
+ const branches = json[keyword];
507
+ if (!Array.isArray(branches)) continue;
508
+ const mapped = branches.map((branch) => stringifyKeyNames(bySchema, branch, visited));
509
+ // rebuilding regardless would detach a key that had nothing to re-express, dropping its `$ref` and leaking the internal `id`
510
+ if (mapped.some((branch, i) => branch !== branches[i])) json = { ...json, [keyword]: mapped };
511
+ }
512
+
513
+ // a member that already admits a string leaves the key unconstrained, so the node's own type re-expresses only when every member is numeric
514
+ const types = Array.isArray(json.type) ? json.type : [json.type];
515
+ const numericType = !types.includes("string") && types.some((t) => t === "number" || t === "integer");
516
+ // a heterogeneous key carries no type at all, so its numeric members are caught here instead
517
+ const values = json.enum ?? (json.const !== undefined ? [json.const] : undefined);
518
+ if (!numericType && !values?.some((v) => typeof v === "number")) return json;
519
+
520
+ const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, format, id, ...rest } = json;
521
+ if (rest.enum) rest.enum = rest.enum.map((v) => (typeof v === "number" ? String(v) : v));
522
+ else if (typeof rest.const === "number") rest.const = String(rest.const);
523
+ // a heterogeneous key keeps its absent type: the stringified members already say what a key may be
524
+ if (!numericType) return rest;
525
+ rest.type = "string";
526
+ if (!values) rest.pattern = (types.includes("number") ? regexes.number : regexes.integer).source;
527
+ return rest;
528
+ }
529
+
530
+ /** Every record of one conversion, so the carriers are found in a single pass rather than once per record. */
531
+ const pendingRecords = new WeakMap<ToJSONSchemaContext, schemas.$ZodType[]>();
532
+
533
+ function rewriteKeyNames(ctx: ToJSONSchemaContext): void {
534
+ // an extracted key is resolved by the object `extractToDef` left in its place, so the map is built once rather than searched per reference. `_zod.toJSONSchema` can hand the same object to two schemas, so the first entry carrying a body wins, as a search would have found it.
535
+ const bySchema = new Map<JSONSchema.BaseSchema, Seen>();
536
+ for (const entry of ctx.seen.values()) {
537
+ if (entry.def && !bySchema.has(entry.schema)) bySchema.set(entry.schema, entry);
538
+ }
539
+
540
+ const rewrites = new Map<JSONSchema.BaseSchema, JSONSchema.BaseSchema>();
541
+ for (const record of pendingRecords.get(ctx) ?? []) {
542
+ const seen = ctx.seen.get(record);
543
+ const names = (seen?.def ?? seen?.schema)?.propertyNames;
544
+ if (!names || names === true || rewrites.has(names)) continue;
545
+ const rewritten = stringifyKeyNames(bySchema, names, new Set());
546
+ if (rewritten !== names) rewrites.set(names, rewritten);
547
+ }
548
+ if (!rewrites.size) return;
549
+
550
+ // the flatten has already copied each record's own properties onto every wrapper by reference, and an extracted body is another such copy, so every carrier holding a rewritten key is updated together
551
+ for (const entry of ctx.seen.values()) {
552
+ for (const carrier of [entry.schema, entry.def]) {
553
+ const rewritten = carrier && rewrites.get(carrier.propertyNames as JSONSchema.BaseSchema);
554
+ if (rewritten) carrier!.propertyNames = rewritten;
555
+ }
556
+ }
557
+ }
558
+
478
559
  export const recordProcessor: Processor<schemas.$ZodRecord> = (schema, ctx, _json, params) => {
479
560
  const json = _json as JSONSchema.ObjectSchema;
480
561
  const def = schema._zod.def as schemas.$ZodRecordDef;
@@ -502,6 +583,13 @@ export const recordProcessor: Processor<schemas.$ZodRecord> = (schema, ctx, _jso
502
583
  ...params,
503
584
  path: [...params.path, "propertyNames"],
504
585
  });
586
+ let pending = pendingRecords.get(ctx);
587
+ if (!pending) {
588
+ pending = [];
589
+ pendingRecords.set(ctx, pending);
590
+ ctx.deferred.push(() => rewriteKeyNames(ctx));
591
+ }
592
+ pending.push(schema);
505
593
  }
506
594
  json.additionalProperties = process(def.valueType, ctx as any, {
507
595
  ...params,
@@ -519,7 +607,7 @@ export const recordProcessor: Processor<schemas.$ZodRecord> = (schema, ctx, _jso
519
607
  );
520
608
 
521
609
  if (validKeyValues.length > 0) {
522
- json.required = validKeyValues as string[];
610
+ json.required = validKeyValues.map(String);
523
611
  }
524
612
  }
525
613
  };
@@ -142,6 +142,8 @@ export interface ToJSONSchemaContext {
142
142
  reused: "ref" | "inline";
143
143
  /** The `allOf` array of each intersection encountered during traversal, innermost first. `finalize` folds every emitted object holding one; see `foldIntersection`. */
144
144
  intersections: JSONSchema.BaseSchema[][];
145
+ /** Rewrites a processor deferred to `finalize`, where the flatten has resolved every ref and the union branches are in place. */
146
+ deferred: (() => void)[];
145
147
  external?:
146
148
  | {
147
149
  registry: $ZodRegistry<{ id?: string | undefined }>;
@@ -180,6 +182,7 @@ export function initializeContext(params: JSONSchemaGeneratorParams): ToJSONSche
180
182
  cycles: params?.cycles ?? "ref",
181
183
  reused: params?.reused ?? "inline",
182
184
  intersections: [],
185
+ deferred: [],
183
186
  external: params?.external ?? undefined,
184
187
  };
185
188
  }
@@ -660,6 +663,8 @@ export function finalize<T extends schemas.$ZodType>(
660
663
  }
661
664
  }
662
665
 
666
+ for (const rewrite of ctx.deferred) rewrite();
667
+
663
668
  // After flattening, every member that was extracted is a `$ref`, so the fold sees the final shape. A schema that inherits an intersection — through `z.lazy`, or any `ref` chain — holds the same `allOf` array, so fold by array identity to catch every copy.
664
669
  if (ctx.intersections.length) {
665
670
  const carriers = new Map<JSONSchema.BaseSchema[], JSONSchema.BaseSchema[]>();
@@ -1,5 +1,5 @@
1
1
  export const version = {
2
2
  major: 4,
3
3
  minor: 5,
4
- patch: 2 as number,
4
+ patch: 3 as number,
5
5
  } as const;
@@ -1,7 +1,31 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
2
25
  Object.defineProperty(exports, "__esModule", { value: true });
3
26
  exports.allProcessors = exports.lazyProcessor = exports.optionalProcessor = exports.promiseProcessor = exports.readonlyProcessor = exports.pipeProcessor = exports.catchProcessor = exports.prefaultProcessor = exports.defaultProcessor = exports.nonoptionalProcessor = exports.nullableProcessor = exports.recordProcessor = exports.tupleProcessor = exports.intersectionProcessor = exports.unionProcessor = exports.objectProcessor = exports.arrayProcessor = exports.setProcessor = exports.mapProcessor = exports.transformProcessor = exports.functionProcessor = exports.customProcessor = exports.successProcessor = exports.fileProcessor = exports.templateLiteralProcessor = exports.nanProcessor = exports.literalProcessor = exports.enumProcessor = exports.dateProcessor = exports.unknownProcessor = exports.anyProcessor = exports.neverProcessor = exports.voidProcessor = exports.undefinedProcessor = exports.nullProcessor = exports.symbolProcessor = exports.bigintProcessor = exports.booleanProcessor = exports.numberProcessor = exports.stringProcessor = void 0;
4
27
  exports.toJSONSchema = toJSONSchema;
28
+ const regexes = __importStar(require("./regexes.cjs"));
5
29
  const to_json_schema_js_1 = require("./to-json-schema.cjs");
6
30
  const util_js_1 = require("./util.cjs");
7
31
  const formatMap = {
@@ -34,12 +58,12 @@ const stringProcessor = (schema, ctx, _json, _params) => {
34
58
  if (contentEncoding)
35
59
  json.contentEncoding = contentEncoding;
36
60
  if (patterns && patterns.size > 0) {
37
- const regexes = [...patterns];
38
- if (regexes.length === 1)
39
- json.pattern = regexes[0].source;
40
- else if (regexes.length > 1) {
61
+ const patternList = [...patterns];
62
+ if (patternList.length === 1)
63
+ json.pattern = patternList[0].source;
64
+ else if (patternList.length > 1) {
41
65
  json.allOf = [
42
- ...regexes.map((regex) => ({
66
+ ...patternList.map((regex) => ({
43
67
  ...(ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0"
44
68
  ? { type: "string" }
45
69
  : {}),
@@ -461,6 +485,87 @@ const tupleProcessor = (schema, ctx, _json, params) => {
461
485
  json.maxItems = maximum;
462
486
  };
463
487
  exports.tupleProcessor = tupleProcessor;
488
+ /** JSON object keys are always strings, so a numeric record key schema is re-expressed over the
489
+ * numeric-string form the record parser matches. Deferred to `finalize`, after the flatten: a key
490
+ * behind a wrapper only carries its own `type` before then, and a union key only has its branches.
491
+ *
492
+ * A numeric bound cannot apply to a property name, so `minimum` and its siblings are dropped rather
493
+ * than carried over: keeping them beside `type: "string"` reproduces the match-nothing schema this
494
+ * exists to fix. A key that carries one therefore emits wider than the record parses — `z.record(z.number().min(5), V)`
495
+ * accepts `"3"` — which is the deliberate trade, since throwing on it would reject an ordinary schema
496
+ * outright. */
497
+ function stringifyKeyNames(bySchema, json, visited) {
498
+ // an extracted key that rewrites cannot go on sharing its definition — the string form a key position needs is not the number form every other reference wants — so it inlines. One that does not rewrite keeps the `$ref`.
499
+ if (json.$ref) {
500
+ // a recursive key holds its own reference inside its definition, so a node already on the path is left alone rather than resolved again
501
+ if (visited.has(json))
502
+ return json;
503
+ visited.add(json);
504
+ const def = bySchema.get(json)?.def;
505
+ if (!def)
506
+ return json;
507
+ const inlined = stringifyKeyNames(bySchema, def, visited);
508
+ return inlined === def ? json : inlined;
509
+ }
510
+ for (const keyword of ["anyOf", "oneOf"]) {
511
+ const branches = json[keyword];
512
+ if (!Array.isArray(branches))
513
+ continue;
514
+ const mapped = branches.map((branch) => stringifyKeyNames(bySchema, branch, visited));
515
+ // rebuilding regardless would detach a key that had nothing to re-express, dropping its `$ref` and leaking the internal `id`
516
+ if (mapped.some((branch, i) => branch !== branches[i]))
517
+ json = { ...json, [keyword]: mapped };
518
+ }
519
+ // a member that already admits a string leaves the key unconstrained, so the node's own type re-expresses only when every member is numeric
520
+ const types = Array.isArray(json.type) ? json.type : [json.type];
521
+ const numericType = !types.includes("string") && types.some((t) => t === "number" || t === "integer");
522
+ // a heterogeneous key carries no type at all, so its numeric members are caught here instead
523
+ const values = json.enum ?? (json.const !== undefined ? [json.const] : undefined);
524
+ if (!numericType && !values?.some((v) => typeof v === "number"))
525
+ return json;
526
+ const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, format, id, ...rest } = json;
527
+ if (rest.enum)
528
+ rest.enum = rest.enum.map((v) => (typeof v === "number" ? String(v) : v));
529
+ else if (typeof rest.const === "number")
530
+ rest.const = String(rest.const);
531
+ // a heterogeneous key keeps its absent type: the stringified members already say what a key may be
532
+ if (!numericType)
533
+ return rest;
534
+ rest.type = "string";
535
+ if (!values)
536
+ rest.pattern = (types.includes("number") ? regexes.number : regexes.integer).source;
537
+ return rest;
538
+ }
539
+ /** Every record of one conversion, so the carriers are found in a single pass rather than once per record. */
540
+ const pendingRecords = new WeakMap();
541
+ function rewriteKeyNames(ctx) {
542
+ // an extracted key is resolved by the object `extractToDef` left in its place, so the map is built once rather than searched per reference. `_zod.toJSONSchema` can hand the same object to two schemas, so the first entry carrying a body wins, as a search would have found it.
543
+ const bySchema = new Map();
544
+ for (const entry of ctx.seen.values()) {
545
+ if (entry.def && !bySchema.has(entry.schema))
546
+ bySchema.set(entry.schema, entry);
547
+ }
548
+ const rewrites = new Map();
549
+ for (const record of pendingRecords.get(ctx) ?? []) {
550
+ const seen = ctx.seen.get(record);
551
+ const names = (seen?.def ?? seen?.schema)?.propertyNames;
552
+ if (!names || names === true || rewrites.has(names))
553
+ continue;
554
+ const rewritten = stringifyKeyNames(bySchema, names, new Set());
555
+ if (rewritten !== names)
556
+ rewrites.set(names, rewritten);
557
+ }
558
+ if (!rewrites.size)
559
+ return;
560
+ // the flatten has already copied each record's own properties onto every wrapper by reference, and an extracted body is another such copy, so every carrier holding a rewritten key is updated together
561
+ for (const entry of ctx.seen.values()) {
562
+ for (const carrier of [entry.schema, entry.def]) {
563
+ const rewritten = carrier && rewrites.get(carrier.propertyNames);
564
+ if (rewritten)
565
+ carrier.propertyNames = rewritten;
566
+ }
567
+ }
568
+ }
464
569
  const recordProcessor = (schema, ctx, _json, params) => {
465
570
  const json = _json;
466
571
  const def = schema._zod.def;
@@ -487,6 +592,13 @@ const recordProcessor = (schema, ctx, _json, params) => {
487
592
  ...params,
488
593
  path: [...params.path, "propertyNames"],
489
594
  });
595
+ let pending = pendingRecords.get(ctx);
596
+ if (!pending) {
597
+ pending = [];
598
+ pendingRecords.set(ctx, pending);
599
+ ctx.deferred.push(() => rewriteKeyNames(ctx));
600
+ }
601
+ pending.push(schema);
490
602
  }
491
603
  json.additionalProperties = (0, to_json_schema_js_1.process)(def.valueType, ctx, {
492
604
  ...params,
@@ -500,7 +612,7 @@ const recordProcessor = (schema, ctx, _json, params) => {
500
612
  if (keyValues && !def.partial && !omittableOnInput) {
501
613
  const validKeyValues = [...keyValues].filter((v) => typeof v === "string" || typeof v === "number");
502
614
  if (validKeyValues.length > 0) {
503
- json.required = validKeyValues;
615
+ json.required = validKeyValues.map(String);
504
616
  }
505
617
  }
506
618
  };
@@ -1,3 +1,4 @@
1
+ import * as regexes from "./regexes.js";
1
2
  import { extractDefs, finalize, handleUnrepresentable, initializeContext, process, } from "./to-json-schema.js";
2
3
  import { assignProp, getEnumValues } from "./util.js";
3
4
  const formatMap = {
@@ -30,12 +31,12 @@ export const stringProcessor = (schema, ctx, _json, _params) => {
30
31
  if (contentEncoding)
31
32
  json.contentEncoding = contentEncoding;
32
33
  if (patterns && patterns.size > 0) {
33
- const regexes = [...patterns];
34
- if (regexes.length === 1)
35
- json.pattern = regexes[0].source;
36
- else if (regexes.length > 1) {
34
+ const patternList = [...patterns];
35
+ if (patternList.length === 1)
36
+ json.pattern = patternList[0].source;
37
+ else if (patternList.length > 1) {
37
38
  json.allOf = [
38
- ...regexes.map((regex) => ({
39
+ ...patternList.map((regex) => ({
39
40
  ...(ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0"
40
41
  ? { type: "string" }
41
42
  : {}),
@@ -429,6 +430,87 @@ export const tupleProcessor = (schema, ctx, _json, params) => {
429
430
  if (typeof maximum === "number")
430
431
  json.maxItems = maximum;
431
432
  };
433
+ /** JSON object keys are always strings, so a numeric record key schema is re-expressed over the
434
+ * numeric-string form the record parser matches. Deferred to `finalize`, after the flatten: a key
435
+ * behind a wrapper only carries its own `type` before then, and a union key only has its branches.
436
+ *
437
+ * A numeric bound cannot apply to a property name, so `minimum` and its siblings are dropped rather
438
+ * than carried over: keeping them beside `type: "string"` reproduces the match-nothing schema this
439
+ * exists to fix. A key that carries one therefore emits wider than the record parses — `z.record(z.number().min(5), V)`
440
+ * accepts `"3"` — which is the deliberate trade, since throwing on it would reject an ordinary schema
441
+ * outright. */
442
+ function stringifyKeyNames(bySchema, json, visited) {
443
+ // an extracted key that rewrites cannot go on sharing its definition — the string form a key position needs is not the number form every other reference wants — so it inlines. One that does not rewrite keeps the `$ref`.
444
+ if (json.$ref) {
445
+ // a recursive key holds its own reference inside its definition, so a node already on the path is left alone rather than resolved again
446
+ if (visited.has(json))
447
+ return json;
448
+ visited.add(json);
449
+ const def = bySchema.get(json)?.def;
450
+ if (!def)
451
+ return json;
452
+ const inlined = stringifyKeyNames(bySchema, def, visited);
453
+ return inlined === def ? json : inlined;
454
+ }
455
+ for (const keyword of ["anyOf", "oneOf"]) {
456
+ const branches = json[keyword];
457
+ if (!Array.isArray(branches))
458
+ continue;
459
+ const mapped = branches.map((branch) => stringifyKeyNames(bySchema, branch, visited));
460
+ // rebuilding regardless would detach a key that had nothing to re-express, dropping its `$ref` and leaking the internal `id`
461
+ if (mapped.some((branch, i) => branch !== branches[i]))
462
+ json = { ...json, [keyword]: mapped };
463
+ }
464
+ // a member that already admits a string leaves the key unconstrained, so the node's own type re-expresses only when every member is numeric
465
+ const types = Array.isArray(json.type) ? json.type : [json.type];
466
+ const numericType = !types.includes("string") && types.some((t) => t === "number" || t === "integer");
467
+ // a heterogeneous key carries no type at all, so its numeric members are caught here instead
468
+ const values = json.enum ?? (json.const !== undefined ? [json.const] : undefined);
469
+ if (!numericType && !values?.some((v) => typeof v === "number"))
470
+ return json;
471
+ const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, format, id, ...rest } = json;
472
+ if (rest.enum)
473
+ rest.enum = rest.enum.map((v) => (typeof v === "number" ? String(v) : v));
474
+ else if (typeof rest.const === "number")
475
+ rest.const = String(rest.const);
476
+ // a heterogeneous key keeps its absent type: the stringified members already say what a key may be
477
+ if (!numericType)
478
+ return rest;
479
+ rest.type = "string";
480
+ if (!values)
481
+ rest.pattern = (types.includes("number") ? regexes.number : regexes.integer).source;
482
+ return rest;
483
+ }
484
+ /** Every record of one conversion, so the carriers are found in a single pass rather than once per record. */
485
+ const pendingRecords = new WeakMap();
486
+ function rewriteKeyNames(ctx) {
487
+ // an extracted key is resolved by the object `extractToDef` left in its place, so the map is built once rather than searched per reference. `_zod.toJSONSchema` can hand the same object to two schemas, so the first entry carrying a body wins, as a search would have found it.
488
+ const bySchema = new Map();
489
+ for (const entry of ctx.seen.values()) {
490
+ if (entry.def && !bySchema.has(entry.schema))
491
+ bySchema.set(entry.schema, entry);
492
+ }
493
+ const rewrites = new Map();
494
+ for (const record of pendingRecords.get(ctx) ?? []) {
495
+ const seen = ctx.seen.get(record);
496
+ const names = (seen?.def ?? seen?.schema)?.propertyNames;
497
+ if (!names || names === true || rewrites.has(names))
498
+ continue;
499
+ const rewritten = stringifyKeyNames(bySchema, names, new Set());
500
+ if (rewritten !== names)
501
+ rewrites.set(names, rewritten);
502
+ }
503
+ if (!rewrites.size)
504
+ return;
505
+ // the flatten has already copied each record's own properties onto every wrapper by reference, and an extracted body is another such copy, so every carrier holding a rewritten key is updated together
506
+ for (const entry of ctx.seen.values()) {
507
+ for (const carrier of [entry.schema, entry.def]) {
508
+ const rewritten = carrier && rewrites.get(carrier.propertyNames);
509
+ if (rewritten)
510
+ carrier.propertyNames = rewritten;
511
+ }
512
+ }
513
+ }
432
514
  export const recordProcessor = (schema, ctx, _json, params) => {
433
515
  const json = _json;
434
516
  const def = schema._zod.def;
@@ -455,6 +537,13 @@ export const recordProcessor = (schema, ctx, _json, params) => {
455
537
  ...params,
456
538
  path: [...params.path, "propertyNames"],
457
539
  });
540
+ let pending = pendingRecords.get(ctx);
541
+ if (!pending) {
542
+ pending = [];
543
+ pendingRecords.set(ctx, pending);
544
+ ctx.deferred.push(() => rewriteKeyNames(ctx));
545
+ }
546
+ pending.push(schema);
458
547
  }
459
548
  json.additionalProperties = process(def.valueType, ctx, {
460
549
  ...params,
@@ -468,7 +557,7 @@ export const recordProcessor = (schema, ctx, _json, params) => {
468
557
  if (keyValues && !def.partial && !omittableOnInput) {
469
558
  const validKeyValues = [...keyValues].filter((v) => typeof v === "string" || typeof v === "number");
470
559
  if (validKeyValues.length > 0) {
471
- json.required = validKeyValues;
560
+ json.required = validKeyValues.map(String);
472
561
  }
473
562
  }
474
563
  };
@@ -47,6 +47,7 @@ function initializeContext(params) {
47
47
  cycles: params?.cycles ?? "ref",
48
48
  reused: params?.reused ?? "inline",
49
49
  intersections: [],
50
+ deferred: [],
50
51
  external: params?.external ?? undefined,
51
52
  };
52
53
  }
@@ -480,6 +481,8 @@ function finalize(ctx, schema) {
480
481
  compactTypeUnion(entry[1].def ?? entry[1].schema);
481
482
  }
482
483
  }
484
+ for (const rewrite of ctx.deferred)
485
+ rewrite();
483
486
  // After flattening, every member that was extracted is a `$ref`, so the fold sees the final shape. A schema that inherits an intersection — through `z.lazy`, or any `ref` chain — holds the same `allOf` array, so fold by array identity to catch every copy.
484
487
  if (ctx.intersections.length) {
485
488
  const carriers = new Map();
@@ -115,6 +115,8 @@ export interface ToJSONSchemaContext {
115
115
  reused: "ref" | "inline";
116
116
  /** The `allOf` array of each intersection encountered during traversal, innermost first. `finalize` folds every emitted object holding one; see `foldIntersection`. */
117
117
  intersections: JSONSchema.BaseSchema[][];
118
+ /** Rewrites a processor deferred to `finalize`, where the flatten has resolved every ref and the union branches are in place. */
119
+ deferred: (() => void)[];
118
120
  external?: {
119
121
  registry: $ZodRegistry<{
120
122
  id?: string | undefined;
@@ -115,6 +115,8 @@ export interface ToJSONSchemaContext {
115
115
  reused: "ref" | "inline";
116
116
  /** The `allOf` array of each intersection encountered during traversal, innermost first. `finalize` folds every emitted object holding one; see `foldIntersection`. */
117
117
  intersections: JSONSchema.BaseSchema[][];
118
+ /** Rewrites a processor deferred to `finalize`, where the flatten has resolved every ref and the union branches are in place. */
119
+ deferred: (() => void)[];
118
120
  external?: {
119
121
  registry: $ZodRegistry<{
120
122
  id?: string | undefined;
@@ -39,6 +39,7 @@ export function initializeContext(params) {
39
39
  cycles: params?.cycles ?? "ref",
40
40
  reused: params?.reused ?? "inline",
41
41
  intersections: [],
42
+ deferred: [],
42
43
  external: params?.external ?? undefined,
43
44
  };
44
45
  }
@@ -472,6 +473,8 @@ export function finalize(ctx, schema) {
472
473
  compactTypeUnion(entry[1].def ?? entry[1].schema);
473
474
  }
474
475
  }
476
+ for (const rewrite of ctx.deferred)
477
+ rewrite();
475
478
  // After flattening, every member that was extracted is a `$ref`, so the fold sees the final shape. A schema that inherits an intersection — through `z.lazy`, or any `ref` chain — holds the same `allOf` array, so fold by array identity to catch every copy.
476
479
  if (ctx.intersections.length) {
477
480
  const carriers = new Map();
@@ -4,5 +4,5 @@ exports.version = void 0;
4
4
  exports.version = {
5
5
  major: 4,
6
6
  minor: 5,
7
- patch: 2,
7
+ patch: 3,
8
8
  };
@@ -1,5 +1,5 @@
1
1
  export const version = {
2
2
  major: 4,
3
3
  minor: 5,
4
- patch: 2,
4
+ patch: 3,
5
5
  };