| name | zod-json-schema |
| description | Best practices for generating JSON Schema from Zod v4 schemas using z.toJSONSchema(). Covers target versions, metadata registries, override callbacks, io modes, transform handling, regen-check tests, and serving schemas as HTTP routes. Load this skill when converting Zod schemas to JSON Schema or building schema generation scripts.
|
Zod JSON Schema
Zod v4 has native z.toJSONSchema() for converting Zod schemas to JSON Schema.
This skill covers the options, gotchas, and patterns for using it correctly.
Basic usage
import { z } from 'zod'
const schema = z.object({
name: z.string(),
age: z.number(),
})
z.toJSONSchema(schema)
Options reference
z.toJSONSchema(schema, {
target: 'draft-7',
metadata: z.globalRegistry,
reused: 'inline',
unrepresentable: 'any',
io: 'input',
cycles: 'ref',
override: (ctx) => { },
})
Target version
draft-07 uses definitions/ for shared schemas. draft-2020-12 uses $defs/.
When generating schemas for IDE autocomplete (JSON/YAML language servers), draft-07
has the widest compatibility.
z.toJSONSchema(schema, { target: 'draft-7' })
Zod v4 with target: 'draft-7' automatically adds $schema: "http://json-schema.org/draft-07/schema#"
to the output. No need to prepend it manually.
Named definitions with .meta({ id })
Register schemas with .meta({ id: 'Name' }) and pass metadata: z.globalRegistry
to extract them as definitions/Name. Use reused: 'inline' to avoid auto-generated
__schema0 names for unnamed reused schemas.
export const iconSchema = z
.union([z.string(), iconObjectSchema])
.describe('The icon to be displayed')
.meta({ id: 'iconSchema' })
const configSchema = z.object({
icon: iconSchema.optional(),
})
z.toJSONSchema(configSchema, {
target: 'draft-7',
metadata: z.globalRegistry,
reused: 'inline',
})
Strip redundant id from definitions
Zod copies all .meta() fields into the JSON Schema output, including id.
This is redundant since the definitions/ key already identifies the schema.
The Zod author recommends stripping it with override (colinhacks/zod#4578).
z.toJSONSchema(configSchema, {
target: 'draft-7',
metadata: z.globalRegistry,
reused: 'inline',
override: (ctx) => {
if ('id' in ctx.jsonSchema) {
delete ctx.jsonSchema.id
}
},
})
allOf wrappers around $ref
When .optional() is called on a schema with .meta({ id }), Zod emits
{ allOf: [{ $ref: "#/definitions/X" }] } instead of { $ref }.
This is valid JSON Schema; every tool handles it correctly.
Do not bother unwrapping it unless you have a specific downstream consumer that breaks.
External $ref via .meta() for IDE autocomplete
.meta() fields are copied verbatim into the JSON Schema output. Use .meta({ $ref: '...' })
to inject an external $ref URL that points to a remote schema. IDEs fetch the remote
schema and use it for autocomplete suggestions (e.g. a list of valid icon names).
const lucideIconNameSchema = z
.string()
.meta({ $ref: 'https://example.com/schemas/lucide-icons.json' })
const fontawesomeIconNameSchema = z
.string()
.meta({ $ref: 'https://example.com/schemas/fontawesome-icons.json' })
export const iconSchema = z
.union([lucideIconNameSchema, fontawesomeIconNameSchema, z.string(), iconObjectSchema])
.describe('The icon to be displayed')
.meta({ id: 'iconSchema' })
This produces a union where the first two branches carry external $ref URLs:
{
"anyOf": [
{ "type": "string", "$ref": "https://example.com/schemas/lucide-icons.json" },
{ "type": "string", "$ref": "https://example.com/schemas/fontawesome-icons.json" },
{ "type": "string" },
{ "type": "object", "properties": { "name": { ... } } }
]
}
The remote schema at that URL should be a JSON Schema with an enum array listing
all valid values. IDEs like VS Code fetch it and offer those values as autocomplete.
Serve the remote schemas with CORS headers so browser-based editors can fetch them:
import lucideIconsSchema from './generated/lucide-icons-schema.json' with { type: 'json' }
app.get('/schemas/lucide-icons.json', () =>
Response.json(lucideIconsSchema, { headers: { 'access-control-allow-origin': '*' } }),
)
Handling transforms with io: "input"
z.transform() is unrepresentable in JSON Schema because JSON Schema describes
data shape, not runtime behavior. By default, z.toJSONSchema() represents the
output type (after transforms), which for transforms means {} (any) with
unrepresentable: 'any'.
Use io: "input" to represent the input type instead. This is what you want
when the schema describes what users write (config files, frontmatter, API payloads).
const widthSchema = z.union([z.string(), z.number()]).transform(String)
const schema = z.object({
width: widthSchema.optional(),
})
z.toJSONSchema(schema, { unrepresentable: 'any' })
z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' })
The override callback
override runs for every schema node during traversal. Mutate ctx.jsonSchema directly.
Use it for things Zod doesn't have options for.
z.toJSONSchema(schema, {
unrepresentable: 'any',
override: (ctx) => {
if (ctx.zodSchema._zod.def.type === 'date') {
ctx.jsonSchema.type = 'string'
ctx.jsonSchema.format = 'date-time'
}
},
})
override runs after Zod's default conversion. For unrepresentable types,
set unrepresentable: 'any' alongside override, otherwise Zod throws before
the callback is reached.
Descriptions with .describe()
.describe() text is preserved verbatim in the JSON Schema description field.
Use it on every field that users or agents will see. Keep descriptions concise,
one sentence, starting with a noun or verb.
z.object({
title: z.string().optional().describe('The page title displayed in the sidebar and browser tab'),
hidden: z.boolean().optional().describe('Hide the page from sidebar navigation and search results'),
})
For multiline descriptions, use dedent:
import dedent from 'string-dedent'
z.string().describe(dedent`
Path to an OpenAPI specification file (JSON or YAML), or an array
of paths. Endpoints from the spec are auto-generated as pages
grouped by tag
`)
.passthrough() for extensible schemas
When your schema should accept unknown fields without validation errors (e.g. config
files where users might paste extra fields from another tool), chain .passthrough().
This emits additionalProperties: {} in the JSON Schema.
const configSchema = z.object({
name: z.string(),
colors: colorsSchema.optional(),
}).passthrough()
z.partialRecord() for optional key subsets
z.record(z.enum([...]), z.string()) makes every enum key required in the output.
Use z.partialRecord() when users should only provide a subset.
const socialPlatforms = ['x', 'github', 'discord', 'linkedin'] as const
z.record(z.enum(socialPlatforms), z.string())
z.partialRecord(z.enum(socialPlatforms), z.string())
Regen-check test
Add a test that regenerates the schema and compares to what's on disk.
This catches cases where someone edits the Zod schema but forgets to run the
generation script.
import { test } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { z } from 'zod'
import { mySchema } from './schema.ts'
test('schema.json matches source schemas', () => {
const onDisk = fs.readFileSync(path.join(import.meta.dirname, 'schema.json'), 'utf-8')
const generated = z.toJSONSchema(mySchema, {
target: 'draft-7',
metadata: z.globalRegistry,
reused: 'inline',
unrepresentable: 'any',
override: (ctx) => {
if ('id' in ctx.jsonSchema) delete ctx.jsonSchema.id
},
})
const expected = JSON.stringify(generated, null, 2) + '\n'
if (onDisk !== expected) {
throw new Error('schema.json is out of sync. Run: pnpm generate-schema')
}
})
Generation script pattern
A complete schema generation script with multiple schemas:
import { z } from 'zod'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { configSchema } from '../src/schema.ts'
import { frontmatterSchema } from '../src/lib/frontmatter.ts'
const here = path.dirname(fileURLToPath(import.meta.url))
function stripMetaId(ctx: { jsonSchema: Record<string, unknown> }) {
if ('id' in ctx.jsonSchema) {
delete ctx.jsonSchema.id
}
}
function writeSchema(schema: unknown, filePath: string) {
fs.writeFileSync(filePath, JSON.stringify(schema, null, 2) + '\n')
console.log(`✓ wrote ${path.relative(process.cwd(), filePath)}`)
}
writeSchema(
z.toJSONSchema(configSchema, {
target: 'draft-7',
metadata: z.globalRegistry,
reused: 'inline',
unrepresentable: 'any',
override: stripMetaId,
}),
path.join(here, '..', 'src', 'schema.json'),
)
writeSchema(
z.toJSONSchema(frontmatterSchema, {
target: 'draft-7',
unrepresentable: 'any',
io: 'input',
}),
path.join(here, '..', 'src', 'frontmatter-schema.json'),
)
Serving schemas as HTTP routes
Import the generated JSON and serve it with CORS headers so editors and agents
can fetch the schema at a well-known URL.
import schema from './schema.json' with { type: 'json' }
import frontmatterSchema from './frontmatter-schema.json' with { type: 'json' }
const corsJson = (data: unknown) =>
Response.json(data, { headers: { 'access-control-allow-origin': '*' } })
app.get('/docs.json', () => corsJson(schema))
app.get('/frontmatter.json', () => corsJson(frontmatterSchema))
Users reference the schema URL in their config files for IDE autocomplete:
{
"$schema": "https://example.com/docs.json",
"name": "My Project"
}
For YAML frontmatter in MDX files, the $schema key in the YAML block points
agents to the available fields (editors need the remark-lint-frontmatter-schema
plugin to actually validate it):
---
"$schema": https://example.com/frontmatter.json
title: Getting Started
icon: rocket
---
AJV validation
Validate the generated schema itself is well-formed:
import Ajv from 'ajv'
const ajv = new Ajv({ allErrors: true, strict: false })
const valid = ajv.validateSchema(schema)