Skip to content

Commit 5add964

Browse files
authored
fix(zod): keep required on constraint-only oneOf/anyOf branches (#3783)
* fix(zod): keep required on constraint-only oneOf/anyOf branches A branch that declares no shape of its own and only lists `required` was rendered in isolation, where it has no type to resolve and falls through to `zod.unknown()`. The constraint was silently dropped, so the generated validator accepted payloads the spec rejects. JSON Schema applies every branch to the same instance, so the property types live on the composing schema. Render such branches against those sibling properties instead, which is what the `allOf` path already achieves through `additionalRequired`. A member only counts as shape-less when `required` is all it says, aside from `title`/`description` (annotations, carried over) and `not` (not translated by this generator today). Members carrying `enum`, `const`, `additionalProperties`, `nullable` or `default` already render to something meaningful and are left untouched. * fix(zod): skip branches whose required keys have no sibling schema A branch may require a key the composing schema never declares. Rewriting it produced an object that omitted the key entirely, so an empty payload matched a branch that should have rejected it. zod cannot express `present, type unspecified` — `unknown` and `any` are both optional inside an object — so such a branch now keeps the existing behaviour instead of an object that only looks like it enforces the constraint. Also pin both branch bodies whole in the regression tests; the previous assertions stopped short of the last property and would have passed had it gone missing.
1 parent 4917ea0 commit 5add964

2 files changed

Lines changed: 255 additions & 1 deletion

File tree

packages/zod/src/index.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,36 @@ const isPlainObjectSchema = (
294294
);
295295
};
296296

297+
// Keywords a member may carry while still describing no shape of its own:
298+
// `title` and `description` only annotate, and `not` is not translated into
299+
// anything by this generator today, so a branch carrying one renders as bare
300+
// `zod.unknown()` either way. Anything outside this set — `enum`, `const`,
301+
// `additionalProperties`, `nullable`, `default`, … — already renders to
302+
// something meaningful on its own and must be left alone.
303+
const SHAPELESS_MEMBER_KEYS = new Set([
304+
'required',
305+
'title',
306+
'description',
307+
'not',
308+
]);
309+
310+
// A `oneOf`/`anyOf` member that declares no shape of its own and only narrows
311+
// which of the sibling properties must be present — e.g. two branches that each
312+
// `required` a different pair of keys. JSON Schema applies every branch to the
313+
// same instance, so the property types live on the composing schema rather than
314+
// on the branch. Rendered in isolation such a member has no type to resolve and
315+
// falls through to `zod.unknown()`, silently dropping its `required`. (#3780)
316+
const isConstraintOnlyMember = (
317+
member: OpenApiSchemaObject | OpenApiReferenceObject,
318+
): boolean => {
319+
if ('$ref' in member) return false;
320+
const schema = member as OpenApiSchemaObject;
321+
if (!Array.isArray(schema.required) || schema.required.length === 0) {
322+
return false;
323+
}
324+
return Object.keys(schema).every((key) => SHAPELESS_MEMBER_KEYS.has(key));
325+
};
326+
297327
// The branch must declare the discriminator key as a literal value — a `const`
298328
// or an `enum`. Both zod v3 (>=3.20) and v4 build their branch lookup by
299329
// reading discrete literal values off each option, and throw at construction if
@@ -748,11 +778,48 @@ export const generateZodValidationSchemaDefinition = (
748778
]
749779
: undefined;
750780

781+
// Constraint-only branches have to be rendered against the composing
782+
// schema's `properties`, otherwise their `required` is lost. Only for
783+
// `oneOf`/`anyOf` — an `allOf` member already gets the same effect through
784+
// `additionalRequired` above. (#3780)
785+
const withSiblingProperties = (
786+
member: OpenApiSchemaObject | OpenApiReferenceObject,
787+
) => {
788+
const properties = schema.properties;
789+
if (
790+
!(schema.oneOf || schema.anyOf) ||
791+
!isObject(properties) ||
792+
Object.keys(properties).length === 0 ||
793+
!isConstraintOnlyMember(member)
794+
) {
795+
return member as OpenApiSchemaObject;
796+
}
797+
798+
const required = (member as OpenApiSchemaObject).required as string[];
799+
800+
// Every key the branch requires needs a sibling schema to attach to. A key
801+
// with none cannot be made required in zod — both `unknown` and `any` are
802+
// treated as optional inside an object, so `{}` would still match — and
803+
// rewriting would silently drop it. Leave the whole branch as-is rather
804+
// than emit an object that only looks like it enforces the constraint.
805+
if (!required.every((key) => Object.hasOwn(properties, key))) {
806+
return member as OpenApiSchemaObject;
807+
}
808+
809+
return {
810+
type: 'object',
811+
properties,
812+
required,
813+
// carried over so the branch keeps its `.describe(...)`
814+
description: (member as OpenApiSchemaObject).description,
815+
} as OpenApiSchemaObject;
816+
};
817+
751818
// Use index-based naming to ensure uniqueness when processing multiple schemas
752819
// This prevents duplicate schema names when nullable refs are used
753820
const baseSchemas = schemas.map((schema, index) =>
754821
generateZodValidationSchemaDefinition(
755-
schema as OpenApiSchemaObject,
822+
withSiblingProperties(schema),
756823
context,
757824
`${camel(name)}${pascal(getNumberWord(index + 1))}`,
758825
strict,

packages/zod/src/zod.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12146,3 +12146,190 @@ describe('exactOptional (opt-in)', () => {
1214612146
);
1214712147
});
1214812148
});
12149+
12150+
describe('constraint-only oneOf/anyOf branches (#3780)', () => {
12151+
const context = {
12152+
output: { override: { useDates: false } },
12153+
} as unknown as ContextSpec;
12154+
12155+
const render = (schema: OpenApiSchemaObject) =>
12156+
parseZodValidationSchemaDefinition(
12157+
generateZodValidationSchemaDefinition(
12158+
schema,
12159+
context,
12160+
'createExample',
12161+
false,
12162+
false,
12163+
{ required: true },
12164+
),
12165+
context,
12166+
false,
12167+
false,
12168+
false,
12169+
).zod;
12170+
12171+
// Property types live on the composing schema; each branch only says which of
12172+
// them must be present.
12173+
const constraintOnlyOneOf: OpenApiSchemaObject = {
12174+
type: 'object',
12175+
oneOf: [
12176+
{ title: 'AB', required: ['A', 'B'] },
12177+
{ title: 'XY', required: ['X', 'Y'] },
12178+
],
12179+
properties: {
12180+
A: { type: 'string' },
12181+
B: { type: 'integer' },
12182+
X: { type: 'string' },
12183+
Y: { type: 'integer' },
12184+
},
12185+
};
12186+
12187+
it('applies each branch required to the sibling properties', () => {
12188+
const zod = render(constraintOnlyOneOf);
12189+
12190+
expect(zod).not.toContain('zod.unknown()');
12191+
// pin both branches whole, so a key that goes missing or flips its
12192+
// requiredness cannot slip through
12193+
expect(zod).toContain(
12194+
'zod.object({\n "A": zod.string(),\n "B": zod.number().int(),\n "X": zod.string().optional(),\n "Y": zod.number().int().optional()\n})',
12195+
);
12196+
expect(zod).toContain(
12197+
'zod.object({\n "A": zod.string().optional(),\n "B": zod.number().int().optional(),\n "X": zod.string(),\n "Y": zod.number().int()\n})',
12198+
);
12199+
});
12200+
12201+
it('treats anyOf the same way', () => {
12202+
const zod = render({
12203+
...constraintOnlyOneOf,
12204+
oneOf: undefined,
12205+
anyOf: constraintOnlyOneOf.oneOf,
12206+
} as OpenApiSchemaObject);
12207+
12208+
expect(zod).not.toContain('zod.unknown()');
12209+
expect(zod).toContain(
12210+
'zod.object({\n "A": zod.string(),\n "B": zod.number().int(),\n "X": zod.string().optional(),\n "Y": zod.number().int().optional()\n})',
12211+
);
12212+
expect(zod).toContain(
12213+
'zod.object({\n "A": zod.string().optional(),\n "B": zod.number().int().optional(),\n "X": zod.string(),\n "Y": zod.number().int()\n})',
12214+
);
12215+
});
12216+
12217+
it('leaves branches that declare their own shape untouched', () => {
12218+
const zod = render({
12219+
type: 'object',
12220+
oneOf: [
12221+
{ type: 'object', properties: { A: { type: 'string' } } },
12222+
{ type: 'string' },
12223+
],
12224+
properties: { B: { type: 'integer' } },
12225+
});
12226+
12227+
// the object branch keeps only its own property — the sibling `B` is not
12228+
// pulled in — and the scalar branch stays a scalar
12229+
expect(zod).toContain(
12230+
'zod.union([zod.object({\n "A": zod.string().optional()\n}),zod.string()])',
12231+
);
12232+
});
12233+
12234+
it.each([
12235+
[
12236+
'additionalProperties',
12237+
{ additionalProperties: { type: 'string' } },
12238+
'zod.record(',
12239+
],
12240+
['enum', { enum: ['x', 'y'] }, "zod.enum(['x', 'y'])"],
12241+
['const', { const: 'x' }, 'zod.literal("x")'],
12242+
['nullable', { nullable: true }, 'zod.unknown().nullable()'],
12243+
])('leaves a member carrying %s alone', (_label, extra, expected) => {
12244+
const zod = render({
12245+
type: 'object',
12246+
oneOf: [{ required: ['A'], ...extra }, { required: ['B'] }],
12247+
properties: { A: { type: 'string' }, B: { type: 'integer' } },
12248+
} as OpenApiSchemaObject);
12249+
12250+
// the member renders on its own terms, not as the sibling properties
12251+
expect(zod).toContain(expected);
12252+
});
12253+
12254+
// The schema in #3780 pairs `required` with a `not`, which this generator does
12255+
// not translate at all, so it must not stop the branch from being rewritten.
12256+
it('rewrites a branch that also carries not', () => {
12257+
const zod = render({
12258+
type: 'object',
12259+
oneOf: [
12260+
{
12261+
title: 'AB',
12262+
required: ['A', 'B'],
12263+
not: { anyOf: [{ required: ['X'] }, { required: ['Y'] }] },
12264+
},
12265+
{ title: 'XY', required: ['X', 'Y'] },
12266+
],
12267+
properties: {
12268+
A: { type: 'string' },
12269+
B: { type: 'integer' },
12270+
X: { type: 'string' },
12271+
Y: { type: 'integer' },
12272+
},
12273+
} as OpenApiSchemaObject);
12274+
12275+
expect(zod).not.toContain('zod.unknown()');
12276+
// the `not` branch is still the AB shape, not an all-optional object
12277+
expect(zod).toContain(
12278+
'zod.object({\n "A": zod.string(),\n "B": zod.number().int(),\n "X": zod.string().optional(),\n "Y": zod.number().int().optional()\n})',
12279+
);
12280+
expect(zod).toContain(
12281+
'zod.object({\n "A": zod.string().optional(),\n "B": zod.number().int().optional(),\n "X": zod.string(),\n "Y": zod.number().int()\n})',
12282+
);
12283+
});
12284+
12285+
it('keeps the description of a rewritten branch', () => {
12286+
const zod = render({
12287+
type: 'object',
12288+
oneOf: [{ required: ['A'], description: 'the A case' }],
12289+
properties: { A: { type: 'string' }, B: { type: 'integer' } },
12290+
});
12291+
12292+
expect(zod).not.toContain('zod.unknown()');
12293+
expect(zod).toContain(".describe('the A case')");
12294+
});
12295+
12296+
it('leaves a branch alone when a required key has no sibling property', () => {
12297+
const zod = render({
12298+
type: 'object',
12299+
oneOf: [{ required: ['kind'] }, { required: ['A'] }],
12300+
properties: { A: { type: 'string' } },
12301+
} as OpenApiSchemaObject);
12302+
12303+
// `kind` has no schema to attach to and zod cannot require an untyped key,
12304+
// so that branch keeps the existing behaviour instead of pretending to
12305+
// enforce it; the representable branch is still rewritten
12306+
expect(zod).toContain('zod.union([zod.unknown(),zod.object({');
12307+
expect(zod).toContain('"A": zod.string()');
12308+
});
12309+
12310+
it('leaves the branches alone when there are no sibling properties to apply', () => {
12311+
const zod = render({
12312+
type: 'object',
12313+
oneOf: [{ required: ['A'] }, { required: ['B'] }],
12314+
properties: {},
12315+
} as OpenApiSchemaObject);
12316+
12317+
// nothing to mark required, so rewriting would only narrow the branch from
12318+
// "anything" to "any object" without expressing the constraint
12319+
expect(zod).toContain('zod.union([zod.unknown(),zod.unknown()])');
12320+
});
12321+
12322+
it('does not change allOf, which already collects required across members', () => {
12323+
const zod = render({
12324+
type: 'object',
12325+
allOf: [{ required: ['A'] }],
12326+
properties: { A: { type: 'string' }, B: { type: 'integer' } },
12327+
});
12328+
12329+
// the member itself is untouched and `A` is still marked required through
12330+
// the existing `additionalRequired` path, while `B` stays optional
12331+
expect(zod).toContain('zod.unknown().and(');
12332+
expect(zod).toContain('"A": zod.string(),');
12333+
expect(zod).toContain('"B": zod.number().int().optional()');
12334+
});
12335+
});

0 commit comments

Comments
 (0)