Skip to content

feat: add openapi target to json schema #5052

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 4 additions & 2 deletions packages/docs/content/json-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,9 @@ interface ToJSONSchemaParams {
/** The JSON Schema version to target.
* - `"draft-2020-12"` — Default. JSON Schema Draft 2020-12
* - `"draft-7"` — JSON Schema Draft 7
* - `"draft-4"` — JSON Schema Draft 4 */
target?: "draft-4" | "draft-7" | "draft-2020-12";
* - `"draft-4"` — JSON Schema Draft 4
* - `"openapi-3.0"` — OpenAPI 3.0 Schema Object */
target?: "draft-4" | "draft-7" | "draft-2020-12" | "openapi-3.0";

/** A registry used to look up metadata for each schema.
* Any schema with an `id` property will be extracted as a $def. */
Expand Down Expand Up @@ -252,6 +253,7 @@ To set the target JSON Schema version, use the `target` parameter. By default, Z
z.toJSONSchema(schema, { target: "draft-7" });
z.toJSONSchema(schema, { target: "draft-2020-12" });
z.toJSONSchema(schema, { target: "draft-4" });
z.toJSONSchema(schema, { target: "openapi-3.0" });
```

### `metadata`
Expand Down
19 changes: 19 additions & 0 deletions packages/zod/src/v4/classic/tests/to-json-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,25 @@ describe("toJSONSchema", () => {
`);
});

test("nullable openapi", () => {
expect(z.toJSONSchema(z.string().nullable(), { target: "openapi-3.0" })).toMatchInlineSnapshot(`
{
"nullable": true,
"type": "string",
}
`);
});

test("union with null openapi", () => {
const schema = z.union([z.string(), z.null()]);
expect(z.toJSONSchema(schema, { target: "openapi-3.0" })).toMatchInlineSnapshot(`
{
"nullable": true,
"type": "string",
}
`);
});

test("arrays", () => {
expect(z.toJSONSchema(z.array(z.string()))).toMatchInlineSnapshot(`
{
Expand Down
1 change: 1 addition & 0 deletions packages/zod/src/v4/core/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export type JSONSchema = {
deprecated?: boolean;
readOnly?: boolean;
writeOnly?: boolean;
nullable?: boolean;
examples?: unknown[];
format?: string;
contentMediaType?: string;
Expand Down
42 changes: 34 additions & 8 deletions packages/zod/src/v4/core/to-json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ interface JSONSchemaGeneratorParams {
/** The JSON Schema version to target.
* - `"draft-2020-12"` — Default. JSON Schema Draft 2020-12
* - `"draft-7"` — JSON Schema Draft 7
* - `"draft-4"` — JSON Schema Draft 4 */
target?: "draft-4" | "draft-7" | "draft-2020-12";
* - `"draft-4"` — JSON Schema Draft 4
* - `"openapi-3.0"` — OpenAPI 3.0 Schema Object */
target?: "draft-4" | "draft-7" | "draft-2020-12" | "openapi-3.0";
/** How to handle unrepresentable types.
* - `"throw"` — Default. Unrepresentable types throw an error
* - `"any"` — Unrepresentable types become `{}` */
Expand Down Expand Up @@ -72,7 +73,7 @@ interface Seen {

export class JSONSchemaGenerator {
metadataRegistry: $ZodRegistry<Record<string, any>>;
target: "draft-4" | "draft-7" | "draft-2020-12";
target: "draft-4" | "draft-7" | "draft-2020-12" | "openapi-3.0";
unrepresentable: "throw" | "any";
override: (ctx: {
zodSchema: schemas.$ZodTypes;
Expand Down Expand Up @@ -164,7 +165,9 @@ export class JSONSchemaGenerator {
else if (regexes.length > 1) {
result.schema.allOf = [
...regexes.map((regex) => ({
...(this.target === "draft-7" || this.target === "draft-4" ? ({ type: "string" } as const) : {}),
...(this.target === "draft-7" || this.target === "draft-4" || this.target === "openapi-3.0"
? ({ type: "string" } as const)
: {}),
pattern: regex.source,
})),
];
Expand Down Expand Up @@ -323,12 +326,24 @@ export class JSONSchemaGenerator {
}
case "union": {
const json: JSONSchema.BaseSchema = _json as any;
json.anyOf = def.options.map((x, i) =>
const options = def.options.map((x, i) =>
this.process(x, {
...params,
path: [...params.path, "anyOf", i],
})
);
if (this.target === "openapi-3.0") {
const nonNull = options.filter((x) => (x as any).type !== "null");
const hasNull = nonNull.length !== options.length;
if (nonNull.length === 1) {
Object.assign(json, nonNull[0]!);
} else {
json.anyOf = nonNull;
}
if (hasNull) (json as any).nullable = true;
} else {
json.anyOf = options;
}
break;
}
case "intersection": {
Expand Down Expand Up @@ -452,7 +467,7 @@ export class JSONSchemaGenerator {
} else if (vals.length === 1) {
const val = vals[0]!;
json.type = val === null ? ("null" as const) : (typeof val as any);
if (this.target === "draft-4") {
if (this.target === "draft-4" || this.target === "openapi-3.0") {
json.enum = [val];
} else {
json.const = val;
Expand Down Expand Up @@ -506,7 +521,13 @@ export class JSONSchemaGenerator {

case "nullable": {
const inner = this.process(def.innerType, params);
_json.anyOf = [inner, { type: "null" }];
if (this.target === "openapi-3.0") {
Object.assign(_json, inner);
(_json as any).nullable = true;
result.ref = def.innerType;
} else {
_json.anyOf = [inner, { type: "null" }];
}
break;
}
case "nonoptional": {
Expand Down Expand Up @@ -773,7 +794,10 @@ export class JSONSchemaGenerator {

// merge referenced schema into current
const refSchema = this.seen.get(ref)!.schema;
if (refSchema.$ref && (params.target === "draft-7" || params.target === "draft-4")) {
if (
refSchema.$ref &&
(params.target === "draft-7" || params.target === "draft-4" || params.target === "openapi-3.0")
) {
schema.allOf = schema.allOf ?? [];
schema.allOf.push(refSchema);
} else {
Expand Down Expand Up @@ -802,6 +826,8 @@ export class JSONSchemaGenerator {
result.$schema = "http://json-schema.org/draft-07/schema#";
} else if (this.target === "draft-4") {
result.$schema = "http://json-schema.org/draft-04/schema#";
} else if (this.target === "openapi-3.0") {
// OpenAPI 3.0 schema objects should not include a $schema property
} else {
// @ts-ignore
console.warn(`Invalid target: ${this.target}`);
Expand Down