Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion packages/zod/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,36 @@ const isPlainObjectSchema = (
);
};

// Keywords a member may carry while still describing no shape of its own:
// `title` and `description` only annotate, and `not` is not translated into
// anything by this generator today, so a branch carrying one renders as bare
// `zod.unknown()` either way. Anything outside this set — `enum`, `const`,
// `additionalProperties`, `nullable`, `default`, … — already renders to
// something meaningful on its own and must be left alone.
const SHAPELESS_MEMBER_KEYS = new Set([
'required',
'title',
'description',
'not',
]);

// A `oneOf`/`anyOf` member that declares no shape of its own and only narrows
// which of the sibling properties must be present — e.g. two branches that each
// `required` a different pair of keys. JSON Schema applies every branch to the
// same instance, so the property types live on the composing schema rather than
// on the branch. Rendered in isolation such a member has no type to resolve and
// falls through to `zod.unknown()`, silently dropping its `required`. (#3780)
const isConstraintOnlyMember = (
member: OpenApiSchemaObject | OpenApiReferenceObject,
): boolean => {
if ('$ref' in member) return false;
const schema = member as OpenApiSchemaObject;
if (!Array.isArray(schema.required) || schema.required.length === 0) {
return false;
}
return Object.keys(schema).every((key) => SHAPELESS_MEMBER_KEYS.has(key));
};

// The branch must declare the discriminator key as a literal value — a `const`
// or an `enum`. Both zod v3 (>=3.20) and v4 build their branch lookup by
// reading discrete literal values off each option, and throw at construction if
Expand Down Expand Up @@ -748,11 +778,31 @@ export const generateZodValidationSchemaDefinition = (
]
: undefined;

// Constraint-only branches have to be rendered against the composing
// schema's `properties`, otherwise their `required` is lost. Only for
// `oneOf`/`anyOf` — an `allOf` member already gets the same effect through
// `additionalRequired` above. (#3780)
const withSiblingProperties = (
member: OpenApiSchemaObject | OpenApiReferenceObject,
) =>
(schema.oneOf || schema.anyOf) &&
isObject(schema.properties) &&
Object.keys(schema.properties).length > 0 &&
isConstraintOnlyMember(member)
? ({
type: 'object',
properties: schema.properties,
required: (member as OpenApiSchemaObject).required,
// carried over so the branch keeps its `.describe(...)`
description: (member as OpenApiSchemaObject).description,
} as OpenApiSchemaObject)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
: (member as OpenApiSchemaObject);

// Use index-based naming to ensure uniqueness when processing multiple schemas
// This prevents duplicate schema names when nullable refs are used
const baseSchemas = schemas.map((schema, index) =>
generateZodValidationSchemaDefinition(
schema as OpenApiSchemaObject,
withSiblingProperties(schema),
context,
`${camel(name)}${pascal(getNumberWord(index + 1))}`,
strict,
Expand Down
161 changes: 161 additions & 0 deletions packages/zod/src/zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12146,3 +12146,164 @@ describe('exactOptional (opt-in)', () => {
);
});
});

describe('constraint-only oneOf/anyOf branches (#3780)', () => {
const context = {
output: { override: { useDates: false } },
} as unknown as ContextSpec;

const render = (schema: OpenApiSchemaObject) =>
parseZodValidationSchemaDefinition(
generateZodValidationSchemaDefinition(
schema,
context,
'createExample',
false,
false,
{ required: true },
),
context,
false,
false,
false,
).zod;

// Property types live on the composing schema; each branch only says which of
// them must be present.
const constraintOnlyOneOf: OpenApiSchemaObject = {
type: 'object',
oneOf: [
{ title: 'AB', required: ['A', 'B'] },
{ title: 'XY', required: ['X', 'Y'] },
],
properties: {
A: { type: 'string' },
B: { type: 'integer' },
X: { type: 'string' },
Y: { type: 'integer' },
},
};

it('applies each branch required to the sibling properties', () => {
const zod = render(constraintOnlyOneOf);

expect(zod).not.toContain('zod.unknown()');
// AB branch: A and B required, X and Y left optional
expect(zod).toContain(
'"A": zod.string(),\n "B": zod.number().int(),\n "X": zod.string().optional(),',
);
// XY branch: the other way round
expect(zod).toContain(
'"A": zod.string().optional(),\n "B": zod.number().int().optional(),\n "X": zod.string(),',
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('treats anyOf the same way', () => {
const zod = render({
...constraintOnlyOneOf,
oneOf: undefined,
anyOf: constraintOnlyOneOf.oneOf,
} as OpenApiSchemaObject);

expect(zod).not.toContain('zod.unknown()');
expect(zod).toContain('"A": zod.string(),\n "B": zod.number().int(),');
});

it('leaves branches that declare their own shape untouched', () => {
const zod = render({
type: 'object',
oneOf: [
{ type: 'object', properties: { A: { type: 'string' } } },
{ type: 'string' },
],
properties: { B: { type: 'integer' } },
});

// the object branch keeps only its own property — the sibling `B` is not
// pulled in — and the scalar branch stays a scalar
expect(zod).toContain(
'zod.union([zod.object({\n "A": zod.string().optional()\n}),zod.string()])',
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it.each([
[
'additionalProperties',
{ additionalProperties: { type: 'string' } },
'zod.record(',
],
['enum', { enum: ['x', 'y'] }, "zod.enum(['x', 'y'])"],
['const', { const: 'x' }, 'zod.literal("x")'],
['nullable', { nullable: true }, 'zod.unknown().nullable()'],
])('leaves a member carrying %s alone', (_label, extra, expected) => {
const zod = render({
type: 'object',
oneOf: [{ required: ['A'], ...extra }, { required: ['B'] }],
properties: { A: { type: 'string' }, B: { type: 'integer' } },
} as OpenApiSchemaObject);

// the member renders on its own terms, not as the sibling properties
expect(zod).toContain(expected);
});

// The schema in #3780 pairs `required` with a `not`, which this generator does
// not translate at all, so it must not stop the branch from being rewritten.
it('rewrites a branch that also carries not', () => {
const zod = render({
type: 'object',
oneOf: [
{
title: 'AB',
required: ['A', 'B'],
not: { anyOf: [{ required: ['X'] }, { required: ['Y'] }] },
},
{ title: 'XY', required: ['X', 'Y'] },
],
properties: {
A: { type: 'string' },
B: { type: 'integer' },
X: { type: 'string' },
Y: { type: 'integer' },
},
} as OpenApiSchemaObject);

expect(zod).not.toContain('zod.unknown()');
});

it('keeps the description of a rewritten branch', () => {
const zod = render({
type: 'object',
oneOf: [{ required: ['A'], description: 'the A case' }],
properties: { A: { type: 'string' }, B: { type: 'integer' } },
});

expect(zod).not.toContain('zod.unknown()');
expect(zod).toContain(".describe('the A case')");
});

it('leaves the branches alone when there are no sibling properties to apply', () => {
const zod = render({
type: 'object',
oneOf: [{ required: ['A'] }, { required: ['B'] }],
properties: {},
} as OpenApiSchemaObject);

// nothing to mark required, so rewriting would only narrow the branch from
// "anything" to "any object" without expressing the constraint
expect(zod).toContain('zod.union([zod.unknown(),zod.unknown()])');
});

it('does not change allOf, which already collects required across members', () => {
const zod = render({
type: 'object',
allOf: [{ required: ['A'] }],
properties: { A: { type: 'string' }, B: { type: 'integer' } },
});

// the member itself is untouched and `A` is still marked required through
// the existing `additionalRequired` path, while `B` stays optional
expect(zod).toContain('zod.unknown().and(');
expect(zod).toContain('"A": zod.string(),');
expect(zod).toContain('"B": zod.number().int().optional()');
});
});
Loading