Skip to content

Commit 99f64fe

Browse files
v4(toJSONSchema): Add multiple string pattern support (#4511)
* enhance string patterns * fix other references * use last added pattern * apply code review suggestion * nit * remove extra imports * Tweak --------- Co-authored-by: Colin McDonnell <[email protected]>
1 parent 2228c48 commit 99f64fe

File tree

4 files changed

+87
-20
lines changed

4 files changed

+87
-20
lines changed

packages/zod/src/v4/classic/tests/json-schema.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,52 @@ describe("toJSONSchema", () => {
368368
`);
369369
});
370370

371+
test("string patterns", () => {
372+
expect(z.toJSONSchema(z.string().startsWith("hello").includes("cruel").endsWith("world"))).toMatchInlineSnapshot(`
373+
{
374+
"$schema": "https://json-schema.org/draft/2020-12/schema",
375+
"allOf": [
376+
{
377+
"pattern": "^hello.*",
378+
"type": "string",
379+
},
380+
{
381+
"pattern": "cruel",
382+
"type": "string",
383+
},
384+
{
385+
"pattern": ".*world$",
386+
"type": "string",
387+
},
388+
],
389+
}
390+
`);
391+
392+
expect(
393+
z.toJSONSchema(
394+
z
395+
.string()
396+
.regex(/^hello/)
397+
.regex(/world$/)
398+
)
399+
).toMatchInlineSnapshot(`
400+
{
401+
"$schema": "https://json-schema.org/draft/2020-12/schema",
402+
"allOf": [
403+
{
404+
"format": "regex",
405+
"pattern": "^hello",
406+
"type": "string",
407+
},
408+
{
409+
"pattern": "world$",
410+
"type": "string",
411+
},
412+
],
413+
}
414+
`);
415+
});
416+
371417
test("number constraints", () => {
372418
expect(z.toJSONSchema(z.number().min(5).max(10))).toMatchInlineSnapshot(
373419
`

packages/zod/src/v4/core/checks.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -787,8 +787,12 @@ export const $ZodCheckStringFormat: core.$constructor<$ZodCheckStringFormat> = /
787787
$ZodCheck.init(inst, def);
788788

789789
inst._zod.onattach.push((inst) => {
790-
inst._zod.bag.format = def.format;
791-
if (def.pattern) inst._zod.bag.pattern = def.pattern;
790+
const bag = inst._zod.bag as schemas.$ZodStringInternals<unknown>["bag"];
791+
bag.format = def.format;
792+
if (def.pattern) {
793+
bag.patterns ??= new Set();
794+
bag.patterns.add(def.pattern);
795+
}
792796
});
793797

794798
inst._zod.check ??= (payload) => {
@@ -951,7 +955,9 @@ export const $ZodCheckIncludes: core.$constructor<$ZodCheckIncludes> = /*@__PURE
951955
const pattern = new RegExp(util.escapeRegex(def.includes));
952956
def.pattern = pattern;
953957
inst._zod.onattach.push((inst) => {
954-
inst._zod.bag.pattern = pattern;
958+
const bag = inst._zod.bag as schemas.$ZodStringInternals<unknown>["bag"];
959+
bag.patterns ??= new Set();
960+
bag.patterns.add(pattern);
955961
});
956962

957963
inst._zod.check = (payload) => {
@@ -993,7 +999,9 @@ export const $ZodCheckStartsWith: core.$constructor<$ZodCheckStartsWith> = /*@__
993999
const pattern = new RegExp(`^${util.escapeRegex(def.prefix)}.*`);
9941000
def.pattern ??= pattern;
9951001
inst._zod.onattach.push((inst) => {
996-
inst._zod.bag.pattern = pattern;
1002+
const bag = inst._zod.bag as schemas.$ZodStringInternals<unknown>["bag"];
1003+
bag.patterns ??= new Set();
1004+
bag.patterns.add(pattern);
9971005
});
9981006

9991007
inst._zod.check = (payload) => {
@@ -1035,7 +1043,9 @@ export const $ZodCheckEndsWith: core.$constructor<$ZodCheckEndsWith> = /*@__PURE
10351043
const pattern = new RegExp(`.*${util.escapeRegex(def.suffix)}$`);
10361044
def.pattern ??= pattern;
10371045
inst._zod.onattach.push((inst) => {
1038-
inst._zod.bag.pattern = new RegExp(`.*${util.escapeRegex(def.suffix)}$`);
1046+
const bag = inst._zod.bag as schemas.$ZodStringInternals<unknown>["bag"];
1047+
bag.patterns ??= new Set();
1048+
bag.patterns.add(pattern);
10391049
});
10401050

10411051
inst._zod.check = (payload) => {

packages/zod/src/v4/core/schemas.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,9 @@ export interface $ZodStringInternals<Input> extends $ZodTypeInternals<string, In
289289
bag: util.LoosePartial<{
290290
minimum: number;
291291
maximum: number;
292-
pattern: RegExp;
292+
patterns: Set<RegExp>;
293293
format: string;
294+
contentEncoding: string;
294295
}>;
295296
}
296297

@@ -300,7 +301,7 @@ export interface $ZodString<Input = unknown> extends $ZodType {
300301

301302
export const $ZodString: core.$constructor<$ZodString> = /*@__PURE__*/ core.$constructor("$ZodString", (inst, def) => {
302303
$ZodType.init(inst, def);
303-
inst._zod.pattern = inst?._zod.bag?.pattern ?? regexes.string(inst._zod.bag);
304+
inst._zod.pattern = [...(inst?._zod.bag?.patterns ?? [])].pop() ?? regexes.string(inst._zod.bag);
304305
inst._zod.parse = (payload, _) => {
305306
if (def.coerce)
306307
try {
@@ -675,7 +676,8 @@ export const $ZodIPv4: core.$constructor<$ZodIPv4> = /*@__PURE__*/ core.$constru
675676
def.pattern ??= regexes.ipv4;
676677
$ZodStringFormat.init(inst, def);
677678
inst._zod.onattach.push((inst) => {
678-
inst._zod.bag.format = `ipv4`;
679+
const bag = inst._zod.bag as $ZodStringInternals<unknown>["bag"];
680+
bag.format = `ipv4`;
679681
});
680682
});
681683

@@ -698,7 +700,8 @@ export const $ZodIPv6: core.$constructor<$ZodIPv6> = /*@__PURE__*/ core.$constru
698700
$ZodStringFormat.init(inst, def);
699701

700702
inst._zod.onattach.push((inst) => {
701-
inst._zod.bag.format = `ipv6`;
703+
const bag = inst._zod.bag as $ZodStringInternals<unknown>["bag"];
704+
bag.format = `ipv6`;
702705
});
703706

704707
inst._zod.check = (payload) => {

packages/zod/src/v4/core/to-json-schema.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,23 +149,31 @@ export class JSONSchemaGenerator {
149149
case "string": {
150150
const json: JSONSchema.StringSchema = _json as any;
151151
json.type = "string";
152-
const { minimum, maximum, format, pattern, contentEncoding } = schema._zod.bag as {
153-
minimum?: number;
154-
maximum?: number;
155-
format?: checks.$ZodStringFormats;
156-
pattern?: RegExp;
157-
contentEncoding?: string;
158-
};
152+
const { minimum, maximum, format, patterns, contentEncoding } = schema._zod
153+
.bag as schemas.$ZodStringInternals<unknown>["bag"];
159154
if (typeof minimum === "number") json.minLength = minimum;
160155
if (typeof maximum === "number") json.maxLength = maximum;
161156
// custom pattern overrides format
162157
if (format) {
163-
json.format = formatMap[format] ?? format;
164-
}
165-
if (pattern) {
166-
json.pattern = pattern.source;
158+
json.format = formatMap[format as checks.$ZodStringFormats] ?? format;
167159
}
168160
if (contentEncoding) json.contentEncoding = contentEncoding;
161+
if (patterns) {
162+
const regexes = [...patterns];
163+
json.pattern = regexes[0].source;
164+
165+
if (regexes.length > 1) {
166+
result.schema = {
167+
allOf: [
168+
json,
169+
...regexes.slice(1).map((regex) => ({
170+
type: "string",
171+
pattern: regex.source,
172+
})),
173+
],
174+
};
175+
}
176+
}
169177

170178
break;
171179
}

0 commit comments

Comments
 (0)