Skip to content

Commit eca9687

Browse files
authored
fix(v4): enforce the six JSON Schema keywords fromJSONSchema silently dropped (#6535)
* fix(v4): enforce the six JSON Schema keywords fromJSONSchema silently dropped `minProperties`/`maxProperties` fold into the pre-pipe guard that already enforces `propertyNames`, so they count the raw input's own keys. `uniqueItems` and `contains`/`minContains`/`maxContains` run as checks on the converted array schema, with structural equality for duplicates. All six are carried into metadata like `propertyNames`, so `toJSONSchema` round-trips them. No public API and no file outside from-json-schema.ts, so mini and non-users pay nothing. Closes #6463 * fix: evaluate the array keywords on the raw instance, pre-pipe `uniqueItems` and the `contains` family moved into a pre-pipe guard mirroring `checkObjectGuards`: a nested `default` in `items` made the post-parse checks accept a `contains` the instance fails and reject distinct items as duplicates. The property-count issues carry explicit messages, since no locale has a Sizable entry for `object`. * test: pin the two side effects of the pre-pipe guards A guard issue aborts the pipe, so `minItems`/`maxItems` are not co-reported, and the guard's identity transform makes `toJSONSchema` strip `default`/`examples` from the input-mode document. Both are the cost of evaluating the keywords against the raw instance; recording them as tests so they read as deliberate. * perf: detect uniqueItems duplicates by canonical key instead of pairwise The pairwise deep-equal scan was quadratic on the parse path: an all-distinct array of 4,000 objects cost 246ms, and nothing bounds the length a converted schema accepts. Each element now hashes to an injective string, so detection is a Map lookup — the same array costs 5ms, and 20,000 elements cost 22ms where the old scan would have taken seconds. A cycle or a NaN, neither expressible in JSON, yields no key and is never called a duplicate; the old comparison would have recursed forever on the cycle. * fix: short-circuit the contains scan and drop carried subschemas that cannot resolve `contains` now stops one past `maxContains`, since nothing above the ceiling changes the verdict; the message for that direction no longer names a count it stopped measuring. A carried `contains` or `propertyNames` travels without the root `$defs` its `$ref`s point into, so one holding a reference is dropped rather than re-emitted as a dangling pointer in an otherwise valid document. * fix: do not read a $ref key inside instance data as a schema reference `default`, `const`, `enum` and `examples` hold instance data, where `$ref` is an ordinary property name. Descending into them made the carry drop a `contains` or `propertyNames` that was perfectly resolvable, widening the emitted schema. * fix: walk only schema positions when looking for a carried $ref Excluding the four standard instance keywords still left every unknown annotation traversed, so an `x-` extension holding a `$ref` key dropped a constraint that resolves. Inverted to an allowlist of the positions a schema can actually occupy. * fix: walk draft-7 dependencies when looking for a carried $ref
1 parent 81ded99 commit eca9687

2 files changed

Lines changed: 403 additions & 25 deletions

File tree

‎packages/zod/src/v4/classic/from-json-schema.ts‎

Lines changed: 246 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -155,34 +155,225 @@ function resolveRef(ref: string, ctx: ConversionContext): JSONSchema.JSONSchema
155155
}
156156

157157
/**
158-
* Rejects every own key that fails `keySchema`, before `objectSchema` runs. The
159-
* guard has to see the raw input: an object parse drops `__proto__` and can add
160-
* keys from a property `default`, so its output is not the set of names the
161-
* instance actually carried.
158+
* Enforces the keywords that constrain an object's own keys — `propertyNames`,
159+
* `minProperties`, `maxProperties` — before `objectSchema` runs. The guard has
160+
* to see the raw input: an object parse drops `__proto__` and can add keys from
161+
* a property `default`, so its output is not the set of names the instance
162+
* actually carried.
162163
*/
163-
function checkPropertyNames(objectSchema: ZodType, keySchema: ZodType): ZodType {
164+
function checkObjectGuards(
165+
objectSchema: ZodType,
166+
guards: { keySchema?: ZodType | undefined; minProperties?: number | undefined; maxProperties?: number | undefined }
167+
): ZodType {
164168
// An identity transform, not z.any(), so `toJSONSchema` reports the object on both the input and the output side of the pipe.
165169
const guard = z
166170
.transform((value: unknown) => value)
167171
.check((payload) => {
168172
const value = payload.value;
169173
if (typeof value !== "object" || value === null || Array.isArray(value)) return;
170-
for (const key of Object.getOwnPropertyNames(value)) {
171-
const result = keySchema.safeParse(key);
172-
if (result.success) continue;
174+
const keys = Object.getOwnPropertyNames(value);
175+
if (guards.minProperties !== undefined && keys.length < guards.minProperties) {
173176
payload.issues.push({
174-
code: "invalid_key",
175-
origin: "record",
176-
issues: result.error.issues,
177-
input: key,
178-
path: [key],
177+
origin: "object",
178+
code: "too_small",
179+
minimum: guards.minProperties,
180+
inclusive: true,
181+
message: `Too small: expected object to have >=${guards.minProperties} properties`,
182+
input: value,
183+
inst: objectSchema,
179184
continue: true,
180185
});
181186
}
187+
if (guards.maxProperties !== undefined && keys.length > guards.maxProperties) {
188+
payload.issues.push({
189+
origin: "object",
190+
code: "too_big",
191+
maximum: guards.maxProperties,
192+
inclusive: true,
193+
message: `Too big: expected object to have <=${guards.maxProperties} properties`,
194+
input: value,
195+
inst: objectSchema,
196+
continue: true,
197+
});
198+
}
199+
if (guards.keySchema) {
200+
for (const key of keys) {
201+
const result = guards.keySchema.safeParse(key);
202+
if (result.success) continue;
203+
payload.issues.push({
204+
code: "invalid_key",
205+
origin: "record",
206+
issues: result.error.issues,
207+
input: key,
208+
path: [key],
209+
continue: true,
210+
});
211+
}
212+
}
182213
});
183214
return guard.pipe(objectSchema);
184215
}
185216

217+
/**
218+
* An injective string for JSON data: two values share a key exactly when `uniqueItems` calls them
219+
* equal, so duplicate detection is a Map lookup rather than a pairwise walk. Strings and keys are
220+
* length-prefixed so no value can spell another one. `null` means "never equal to anything" — a
221+
* cycle or a NaN, neither of which JSON can express.
222+
*/
223+
function canonicalKey(value: unknown, seen: Set<object>): string | null {
224+
if (value === null) return "z";
225+
const type = typeof value;
226+
if (type !== "object") {
227+
if (type === "number" && Number.isNaN(value)) return null;
228+
const raw = String(value);
229+
return `${type[0]}${raw.length}:${raw}`;
230+
}
231+
if (seen.has(value as object)) return null;
232+
seen.add(value as object);
233+
try {
234+
if (Array.isArray(value)) {
235+
const parts: string[] = [];
236+
for (const item of value) {
237+
const key = canonicalKey(item, seen);
238+
if (key === null) return null;
239+
parts.push(key);
240+
}
241+
return `a${parts.length}:[${parts.join(",")}]`;
242+
}
243+
const keys = Object.keys(value as object).sort();
244+
const parts: string[] = [];
245+
for (const k of keys) {
246+
const key = canonicalKey((value as any)[k], seen);
247+
if (key === null) return null;
248+
parts.push(`${k.length}:${k}=${key}`);
249+
}
250+
return `o${parts.length}:{${parts.join(",")}}`;
251+
} finally {
252+
seen.delete(value as object);
253+
}
254+
}
255+
256+
// keywords whose value is a schema or an array of them
257+
const SCHEMA_KEYWORDS = /*@__PURE__*/ new Set([
258+
"items",
259+
"prefixItems",
260+
"additionalItems",
261+
"additionalProperties",
262+
"contains",
263+
"propertyNames",
264+
"not",
265+
"if",
266+
"then",
267+
"else",
268+
"allOf",
269+
"anyOf",
270+
"oneOf",
271+
"unevaluatedItems",
272+
"unevaluatedProperties",
273+
"contentSchema",
274+
]);
275+
276+
// keywords whose value maps names to schemas. draft-7 `dependencies` also allows an array of property names, which holds no schema to walk
277+
const SCHEMA_MAP_KEYWORDS = /*@__PURE__*/ new Set([
278+
"properties",
279+
"patternProperties",
280+
"dependentSchemas",
281+
"dependencies",
282+
"$defs",
283+
"definitions",
284+
]);
285+
286+
/**
287+
* True when a subschema holds a `$ref` anywhere a schema can appear. Only those positions are
288+
* walked: `$ref` under `default` or an `x-` annotation is instance data with an ordinary key, and
289+
* reading it as a reference would drop a constraint that resolves perfectly well.
290+
*/
291+
function containsRef(value: unknown): boolean {
292+
if (typeof value !== "object" || value === null) return false;
293+
if (Array.isArray(value)) return value.some(containsRef);
294+
if (typeof (value as any).$ref === "string") return true;
295+
return Object.entries(value).some(([key, sub]) => {
296+
if (SCHEMA_KEYWORDS.has(key)) return containsRef(sub);
297+
if (!SCHEMA_MAP_KEYWORDS.has(key) || typeof sub !== "object" || sub === null) return false;
298+
return Object.values(sub).some(containsRef);
299+
});
300+
}
301+
302+
function plural(n: number): string {
303+
return n === 1 ? "element" : "elements";
304+
}
305+
306+
/**
307+
* Enforces `uniqueItems` and the `contains` family before `arraySchema` runs, for the same reason
308+
* `checkObjectGuards` runs pre-pipe: an item parse can apply a nested `default`, so the parsed
309+
* array is not the instance the keywords are defined over. A guard issue aborts the pipe, so
310+
* `minItems`/`maxItems` are not also reported on the same parse — instance correctness is worth
311+
* more than co-reporting two issues.
312+
*/
313+
function checkArrayGuards(
314+
arraySchema: ZodType,
315+
guards: {
316+
uniqueItems?: boolean | undefined;
317+
containsSchema?: ZodType | undefined;
318+
minContains?: number | undefined;
319+
maxContains?: number | undefined;
320+
}
321+
): ZodType {
322+
// An identity transform, not z.any(), so `toJSONSchema` reports the array on both the input and the output side of the pipe.
323+
const guard = z
324+
.transform((value: unknown) => value)
325+
.check((payload) => {
326+
const items = payload.value;
327+
if (!Array.isArray(items)) return;
328+
if (guards.uniqueItems === true) {
329+
const firstSeen = new Map<string, number>();
330+
for (let i = 0; i < items.length; i++) {
331+
const key = canonicalKey(items[i], new Set());
332+
if (key === null) continue;
333+
const first = firstSeen.get(key);
334+
if (first === undefined) {
335+
firstSeen.set(key, i);
336+
continue;
337+
}
338+
payload.issues.push({
339+
code: "custom",
340+
message: `Array items must be unique: element at index ${i} duplicates the one at index ${first}`,
341+
input: items,
342+
path: [i],
343+
continue: true,
344+
});
345+
}
346+
}
347+
if (guards.containsSchema) {
348+
const minContains = guards.minContains ?? 1;
349+
// one past the ceiling already proves the failure, and nothing above it changes the verdict
350+
const ceiling = guards.maxContains !== undefined ? guards.maxContains + 1 : Number.POSITIVE_INFINITY;
351+
let matches = 0;
352+
for (const item of items) {
353+
if (guards.containsSchema.safeParse(item).success && ++matches >= ceiling) break;
354+
}
355+
if (matches < minContains) {
356+
// the scan ran to the end, so this count is exact
357+
payload.issues.push({
358+
code: "custom",
359+
message: `Array must contain at least ${minContains} matching ${plural(minContains)}; found ${matches}`,
360+
input: items,
361+
continue: true,
362+
});
363+
}
364+
if (guards.maxContains !== undefined && matches > guards.maxContains) {
365+
payload.issues.push({
366+
code: "custom",
367+
message: `Array must contain at most ${guards.maxContains} matching ${plural(guards.maxContains)}`,
368+
input: items,
369+
continue: true,
370+
});
371+
}
372+
}
373+
});
374+
return guard.pipe(arraySchema);
375+
}
376+
186377
function getTupleRest(restSchema: JSONSchema._JSONSchema | undefined, ctx: ConversionContext): ZodType | undefined {
187378
if (restSchema === false) {
188379
return undefined;
@@ -519,20 +710,25 @@ function convertBaseSchema(schema: JSONSchema.JSONSchema, ctx: ConversionContext
519710
}
520711

521712
// propertyNames constrains key *names* only, and says nothing about which keys are required or how their values validate. Layering it on top of the result keeps properties/patternProperties/additionalProperties composing underneath. `true` allows every name, so it needs no guard.
522-
if (schema.propertyNames !== undefined && schema.propertyNames !== true) {
523-
// Keys are always strings, so a propertyNames subschema that omits `type` still constrains them — without this it would convert to z.any().
524-
const keyJSONSchema =
525-
typeof schema.propertyNames === "object" && schema.propertyNames.type === undefined
526-
? { type: "string", ...schema.propertyNames }
527-
: schema.propertyNames;
528-
zodSchema = checkPropertyNames(zodSchema, convertSchema(keyJSONSchema as JSONSchema.JSONSchema, ctx));
713+
const hasKeyGuard = schema.propertyNames !== undefined && schema.propertyNames !== true;
714+
const minProperties = typeof schema.minProperties === "number" ? schema.minProperties : undefined;
715+
const maxProperties = typeof schema.maxProperties === "number" ? schema.maxProperties : undefined;
716+
if (hasKeyGuard || minProperties !== undefined || maxProperties !== undefined) {
717+
let keySchema: ZodType | undefined;
718+
if (hasKeyGuard) {
719+
// Keys are always strings, so a propertyNames subschema that omits `type` still constrains them — without this it would convert to z.any().
720+
const keyJSONSchema =
721+
typeof schema.propertyNames === "object" && schema.propertyNames.type === undefined
722+
? { type: "string", ...schema.propertyNames }
723+
: schema.propertyNames;
724+
keySchema = convertSchema(keyJSONSchema as JSONSchema.JSONSchema, ctx);
725+
}
726+
zodSchema = checkObjectGuards(zodSchema, { keySchema, minProperties, maxProperties });
529727
}
530728
break;
531729
}
532730

533731
case "array": {
534-
// TODO: uniqueItems and contains/minContains/maxContains are not supported
535-
536732
// Check if this is a tuple (prefixItems or items as array)
537733
const prefixItems = schema.prefixItems;
538734
const items = schema.items;
@@ -585,6 +781,18 @@ function convertBaseSchema(schema: JSONSchema.JSONSchema, ctx: ConversionContext
585781
// No items specified - array of any
586782
zodSchema = z.array(z.any());
587783
}
784+
785+
// minContains/maxContains only constrain anything when `contains` itself is present
786+
if (schema.uniqueItems === true || schema.contains !== undefined) {
787+
zodSchema = checkArrayGuards(zodSchema, {
788+
uniqueItems: schema.uniqueItems === true,
789+
containsSchema:
790+
schema.contains !== undefined ? convertSchema(schema.contains as JSONSchema.JSONSchema, ctx) : undefined,
791+
minContains: typeof schema.minContains === "number" ? schema.minContains : undefined,
792+
maxContains: typeof schema.maxContains === "number" ? schema.maxContains : undefined,
793+
});
794+
}
795+
588796
break;
589797
}
590798

@@ -666,9 +874,22 @@ function convertSchema(schema: JSONSchema.JSONSchema | boolean, ctx: ConversionC
666874
}
667875
}
668876

669-
// `propertyNames` is enforced by a key guard, which `toJSONSchema` cannot infer, so the original keyword is carried as metadata to keep the round-trip lossless. Only where it was actually applied: on any other type it is inert, and on a `$ref` the metadata would land on the target every reference shares.
670-
if (schema.propertyNames !== undefined && schema.type === "object" && schema.$ref === undefined) {
671-
extraMeta.propertyNames = schema.propertyNames;
877+
// `propertyNames` and `contains` are enforced by a guard, which `toJSONSchema` cannot infer, so the original keyword is carried as metadata to keep the round-trip lossless. Only where it was actually applied: on any other type it is inert, and on a `$ref` the metadata would land on the target every reference shares. A carried subschema is copied verbatim while the root `$defs` stay behind, so one holding a `$ref` is dropped rather than emitted as a dangling reference.
878+
if (schema.type === "object" && schema.$ref === undefined) {
879+
if (schema.propertyNames !== undefined && !containsRef(schema.propertyNames)) {
880+
extraMeta.propertyNames = schema.propertyNames;
881+
}
882+
for (const key of ["minProperties", "maxProperties"] as const) {
883+
if (schema[key] !== undefined) extraMeta[key] = schema[key];
884+
}
885+
}
886+
if (schema.type === "array" && schema.$ref === undefined) {
887+
if (schema.contains !== undefined && !containsRef(schema.contains)) {
888+
extraMeta.contains = schema.contains;
889+
}
890+
for (const key of ["uniqueItems", "minContains", "maxContains"] as const) {
891+
if (schema[key] !== undefined) extraMeta[key] = schema[key];
892+
}
672893
}
673894

674895
for (const key of Object.keys(schema)) {

0 commit comments

Comments
 (0)