Skip to content

Commit 19c5b62

Browse files
committed
fix(core-internal): null-tolerant array/tuple elements; quiet never verdicts; annotation-safe oneOf rewrite
- Array/tuple ELEMENT positions: JSON.stringify turns an undefined element into null (unlike an undefined-valued object key, which it drops), so a tolerant element (.default()/.prefault(), ...) may ship as null while the advertised items/prefixItems subschema rejected it — z.array(z.number().default(0)) output rejected the wire form [1, null, 3]. The override now wraps such emitted subschemas as {anyOf: [original, {type: 'null'}]} (loosen-only), mirroring how the catch degrade already behaves at the same position. - z.never() union members yielded undefined verdicts and defeated the every-member rule: z.union([z.never(), z.bigint()]) threw pre-#2464 but listed as a phantom. never now returns a QUIET non-object verdict (it matches no value, so it can never make a union satisfiable or object-shaped) — loud co-members restore the pre-#2464 throw while bare never, never+never, and never+representable shapes keep listing exactly as they did pre-#2464 (never is deliberately NOT in the loud set). - rewriteOneOfToAnyOf walked blindly into annotation VALUES via Object.values, renaming a literal user-data oneOf key inside default/examples (advertised default: {oneOf: [1,2]} became {anyOf: [1,2]}). The walk now skips ANNOTATION_JSON_SCHEMA_KEYWORDS plus const/enum/x-* values, mirroring the catch-degrade loop's annotation carve-out. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent faef197 commit 19c5b62

2 files changed

Lines changed: 81 additions & 1 deletion

File tree

packages/core-internal/src/util/standardSchema.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,32 @@ function zodConversionOptions(
290290
}
291291
return;
292292
}
293+
if (def.type === 'array') {
294+
// JSON.stringify turns an undefined array ELEMENT into `null` (unlike an
295+
// undefined-valued object key, which it drops), so a tolerant element
296+
// (`.default()`/`.prefault()`, …) may ship as null — the advertised item
297+
// subschema must accept it.
298+
const items = ctx.jsonSchema.items;
299+
if (typeof items === 'object' && items !== null && !Array.isArray(items) && hasStructuralMissingKeyTolerance(def.element)) {
300+
loosened.value = true;
301+
ctx.jsonSchema.items = { anyOf: [items, { type: 'null' }] };
302+
}
303+
return;
304+
}
305+
if (def.type === 'tuple') {
306+
// Same wire mechanism per prefix position.
307+
const prefixItems = ctx.jsonSchema.prefixItems;
308+
if (Array.isArray(def.items) && Array.isArray(prefixItems)) {
309+
for (const [index, item] of def.items.entries()) {
310+
const emitted = prefixItems[index];
311+
if (typeof emitted === 'object' && emitted !== null && hasStructuralMissingKeyTolerance(item)) {
312+
loosened.value = true;
313+
prefixItems[index] = { anyOf: [emitted, { type: 'null' }] };
314+
}
315+
}
316+
}
317+
return;
318+
}
293319
if (def.type !== 'object') return;
294320
const isStrict = def.catchall?._zod.def.type === 'never';
295321
if (!isStrict && ctx.jsonSchema.additionalProperties === false) {
@@ -331,7 +357,13 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set<unknown> = new Set()): voi
331357
record.anyOf = record.oneOf;
332358
delete record.oneOf;
333359
}
334-
for (const value of Object.values(record)) rewriteOneOfToAnyOf(value, seen);
360+
for (const [key, value] of Object.entries(record)) {
361+
// Annotation keywords (and const/enum) carry user DATA, not schemas — a
362+
// plain-data object inside them may legitimately have a literal `oneOf` key
363+
// that must not be renamed.
364+
if (ANNOTATION_JSON_SCHEMA_KEYWORDS.has(key) || key === 'const' || key === 'enum' || key.startsWith('x-')) continue;
365+
rewriteOneOfToAnyOf(value, seen);
366+
}
335367
}
336368

337369
/**
@@ -683,6 +715,14 @@ function nonObjectTypelessRootVerdict(
683715
const loudness = nonObjectLiteralLoudness(def.values);
684716
return loudness === undefined ? undefined : { type: 'literal', loud: loudness === 'loud' };
685717
}
718+
if (def.type === 'never') {
719+
// z.never() matches no value: it can never make a union satisfiable or
720+
// object-shaped. Quiet, though — bare/all-never shapes emitted `{not: {}}`
721+
// without throwing pre-#2464 and must keep listing; the verdict only turns a
722+
// union loud when a loud co-member (e.g. a bigint) is present, restoring the
723+
// pre-#2464 throw for those.
724+
return { type: 'never', loud: false };
725+
}
686726
return NON_OBJECT_UNREPRESENTABLE_TYPES.has(def.type) ? { type: def.type, loud: true } : undefined;
687727
}
688728

packages/core-internal/test/util/standardSchema.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,46 @@ describe('zod conversion options (#2464)', () => {
445445
expect(new AjvJsonSchemaValidator().getValidator(result)({ t: 'a', x: 'v' }).valid).toBe(true);
446446
});
447447

448+
test('tolerant array/tuple elements also accept null in output schemas', () => {
449+
// JSON.stringify turns an undefined array ELEMENT into null (it drops
450+
// undefined-valued object keys), so the raw payload the server validates and
451+
// ships ([1, undefined, 3]) reaches the wire as [1, null, 3].
452+
const schema = z.object({
453+
a: z.array(z.number().default(0)),
454+
t: z.tuple([z.string().default('x'), z.number()]),
455+
name: z.string()
456+
});
457+
const result = standardSchemaToJsonSchema(schema, 'output');
458+
459+
expect(new AjvJsonSchemaValidator().getValidator(result)({ a: [1, null, 3], t: [null, 1], name: 'n' }).valid).toBe(true);
460+
// Non-tolerant elements keep their strict subschema.
461+
const plain = standardSchemaToJsonSchema(z.object({ a: z.array(z.number()) }), 'output');
462+
expect((plain.properties as Record<string, Record<string, unknown>>).a!.items).toEqual({ type: 'number' });
463+
});
464+
465+
test('z.never() union members are quiet non-object verdicts', () => {
466+
// z.never() matches no value: it cannot make a union satisfiable, so a loud
467+
// co-member must keep the pre-#2464 throw ...
468+
expect(() => standardSchemaToJsonSchema(z.union([z.never(), z.bigint()]), 'input')).toThrow(/must describe objects/);
469+
// ... while quiet shapes (which converted silently pre-#2464) keep listing.
470+
expect(standardSchemaToJsonSchema(z.never(), 'input').type).toBe('object');
471+
expect(standardSchemaToJsonSchema(z.union([z.never(), z.string()]), 'input').type).toBe('object');
472+
expect(standardSchemaToJsonSchema(z.union([z.never(), z.never()]), 'input').type).toBe('object');
473+
});
474+
475+
test('the oneOf rewrite does not descend into annotation values', () => {
476+
const schema = z.object({
477+
cfg: z.object({ oneOf: z.array(z.number()) }).default({ oneOf: [1, 2] }),
478+
counted: z.number().default(0), // trips the loosened flag
479+
name: z.string()
480+
});
481+
const result = standardSchemaToJsonSchema(schema, 'output');
482+
483+
// `default` carries user DATA — its literal `oneOf` key must survive.
484+
const cfg = (result.properties as Record<string, Record<string, unknown>>).cfg!;
485+
expect(cfg.default).toEqual({ oneOf: [1, 2] });
486+
});
487+
448488
test('.catch() and union-nested defaults with async stages are dropped from output required', () => {
449489
const schema = z.object({
450490
c: z

0 commit comments

Comments
 (0)