Skip to content

Commit 75d7ffd

Browse files
committed
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.
1 parent 4917ea0 commit 75d7ffd

2 files changed

Lines changed: 212 additions & 1 deletion

File tree

packages/zod/src/index.ts

Lines changed: 51 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,31 @@ 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+
(schema.oneOf || schema.anyOf) &&
789+
isObject(schema.properties) &&
790+
Object.keys(schema.properties).length > 0 &&
791+
isConstraintOnlyMember(member)
792+
? ({
793+
type: 'object',
794+
properties: schema.properties,
795+
required: (member as OpenApiSchemaObject).required,
796+
// carried over so the branch keeps its `.describe(...)`
797+
description: (member as OpenApiSchemaObject).description,
798+
} as OpenApiSchemaObject)
799+
: (member as OpenApiSchemaObject);
800+
751801
// Use index-based naming to ensure uniqueness when processing multiple schemas
752802
// This prevents duplicate schema names when nullable refs are used
753803
const baseSchemas = schemas.map((schema, index) =>
754804
generateZodValidationSchemaDefinition(
755-
schema as OpenApiSchemaObject,
805+
withSiblingProperties(schema),
756806
context,
757807
`${camel(name)}${pascal(getNumberWord(index + 1))}`,
758808
strict,

packages/zod/src/zod.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12146,3 +12146,164 @@ 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+
// AB branch: A and B required, X and Y left optional
12192+
expect(zod).toContain(
12193+
'"A": zod.string(),\n "B": zod.number().int(),\n "X": zod.string().optional(),',
12194+
);
12195+
// XY branch: the other way round
12196+
expect(zod).toContain(
12197+
'"A": zod.string().optional(),\n "B": zod.number().int().optional(),\n "X": zod.string(),',
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('"A": zod.string(),\n "B": zod.number().int(),');
12210+
});
12211+
12212+
it('leaves branches that declare their own shape untouched', () => {
12213+
const zod = render({
12214+
type: 'object',
12215+
oneOf: [
12216+
{ type: 'object', properties: { A: { type: 'string' } } },
12217+
{ type: 'string' },
12218+
],
12219+
properties: { B: { type: 'integer' } },
12220+
});
12221+
12222+
// the object branch keeps only its own property — the sibling `B` is not
12223+
// pulled in — and the scalar branch stays a scalar
12224+
expect(zod).toContain(
12225+
'zod.union([zod.object({\n "A": zod.string().optional()\n}),zod.string()])',
12226+
);
12227+
});
12228+
12229+
it.each([
12230+
[
12231+
'additionalProperties',
12232+
{ additionalProperties: { type: 'string' } },
12233+
'zod.record(',
12234+
],
12235+
['enum', { enum: ['x', 'y'] }, "zod.enum(['x', 'y'])"],
12236+
['const', { const: 'x' }, 'zod.literal("x")'],
12237+
['nullable', { nullable: true }, 'zod.unknown().nullable()'],
12238+
])('leaves a member carrying %s alone', (_label, extra, expected) => {
12239+
const zod = render({
12240+
type: 'object',
12241+
oneOf: [{ required: ['A'], ...extra }, { required: ['B'] }],
12242+
properties: { A: { type: 'string' }, B: { type: 'integer' } },
12243+
} as OpenApiSchemaObject);
12244+
12245+
// the member renders on its own terms, not as the sibling properties
12246+
expect(zod).toContain(expected);
12247+
});
12248+
12249+
// The schema in #3780 pairs `required` with a `not`, which this generator does
12250+
// not translate at all, so it must not stop the branch from being rewritten.
12251+
it('rewrites a branch that also carries not', () => {
12252+
const zod = render({
12253+
type: 'object',
12254+
oneOf: [
12255+
{
12256+
title: 'AB',
12257+
required: ['A', 'B'],
12258+
not: { anyOf: [{ required: ['X'] }, { required: ['Y'] }] },
12259+
},
12260+
{ title: 'XY', required: ['X', 'Y'] },
12261+
],
12262+
properties: {
12263+
A: { type: 'string' },
12264+
B: { type: 'integer' },
12265+
X: { type: 'string' },
12266+
Y: { type: 'integer' },
12267+
},
12268+
} as OpenApiSchemaObject);
12269+
12270+
expect(zod).not.toContain('zod.unknown()');
12271+
});
12272+
12273+
it('keeps the description of a rewritten branch', () => {
12274+
const zod = render({
12275+
type: 'object',
12276+
oneOf: [{ required: ['A'], description: 'the A case' }],
12277+
properties: { A: { type: 'string' }, B: { type: 'integer' } },
12278+
});
12279+
12280+
expect(zod).not.toContain('zod.unknown()');
12281+
expect(zod).toContain(".describe('the A case')");
12282+
});
12283+
12284+
it('leaves the branches alone when there are no sibling properties to apply', () => {
12285+
const zod = render({
12286+
type: 'object',
12287+
oneOf: [{ required: ['A'] }, { required: ['B'] }],
12288+
properties: {},
12289+
} as OpenApiSchemaObject);
12290+
12291+
// nothing to mark required, so rewriting would only narrow the branch from
12292+
// "anything" to "any object" without expressing the constraint
12293+
expect(zod).toContain('zod.union([zod.unknown(),zod.unknown()])');
12294+
});
12295+
12296+
it('does not change allOf, which already collects required across members', () => {
12297+
const zod = render({
12298+
type: 'object',
12299+
allOf: [{ required: ['A'] }],
12300+
properties: { A: { type: 'string' }, B: { type: 'integer' } },
12301+
});
12302+
12303+
// the member itself is untouched and `A` is still marked required through
12304+
// the existing `additionalRequired` path, while `B` stays optional
12305+
expect(zod).toContain('zod.unknown().and(');
12306+
expect(zod).toContain('"A": zod.string(),');
12307+
expect(zod).toContain('"B": zod.number().int().optional()');
12308+
});
12309+
});

0 commit comments

Comments
 (0)