Zod v4 schemas exported to JSON Schema and checked with Ajv: working versions (tested 2026-10-15)
Export Zod v4 schemas to JSON Schema 2020-12 with z.toJSONSchema, using .meta() ids and descriptions and input vs output modes, then validate the same data with Ajv 2020.
passed4 passed · 0 failed2026-10-15 17:11 UTC- Status
- passed
- Last passed
- 2026-10-15 17:11 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38775053802
The code
Exactly the files that passed. Your agent can pull them with get_recipe("zod-v4-json-schema-export") (install), or see them on GitHub.
{
"name": "zod-v4-json-schema-export",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"zod": "4.6.7",
"ajv": "8.20.0"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15"
}
}
import { z } from "zod";
export const Address = z
.object({ street: z.string().min(1), city: z.string(), zip: z.string().regex(/^\d{5}$/) })
.meta({ id: "Address", description: "Postal address" });
export const Customer = z.object({
id: z.uuid(),
email: z.email(),
name: z.string().min(1).max(80),
tier: z.enum(["free", "pro"]).default("free"),
tags: z.array(z.string()).max(5).optional(),
address: Address,
});
export type Customer = z.infer<typeof Customer>;
/** JSON Schema of what a client may send (defaults make fields optional). */
export function customerInputSchema() {
return z.toJSONSchema(Customer, { target: "draft-2020-12", io: "input" });
}
/** JSON Schema of what the API returns after parsing (defaults applied, so required). */
export function customerOutputSchema() {
return z.toJSONSchema(Customer, { target: "draft-2020-12", io: "output" });
}
import { test } from "node:test";
import assert from "node:assert/strict";
import Ajv2020 from "ajv/dist/2020.js";
import { Customer, customerInputSchema, customerOutputSchema } from "../src/schemas.ts";
const sample = {
id: "5f0c6c1e-3b7a-4c1e-9a52-1d2f3e4a5b6c",
email: "[email protected]",
name: "Ada",
address: { street: "1 Main St", city: "Kenai", zip: "99611" },
};
test("exports draft 2020-12 with metadata and shared $defs", () => {
const s = customerOutputSchema() as any;
assert.equal(s.$schema, "https://json-schema.org/draft/2020-12/schema");
assert.equal(s.properties.address.$ref, "#/$defs/Address");
assert.equal(s.$defs.Address.description, "Postal address");
assert.equal(s.properties.email.format, "email");
assert.equal(s.properties.id.format, "uuid");
assert.equal(s.properties.name.maxLength, 80);
assert.deepEqual(s.properties.tier.enum, ["free", "pro"]);
});
test("input vs output: defaulted fields are optional on input only", () => {
const input = customerInputSchema() as any;
const output = customerOutputSchema() as any;
assert.equal(input.required.includes("tier"), false);
assert.equal(output.required.includes("tier"), true);
assert.equal(output.required.includes("tags"), false);
});
test("Ajv and Zod agree on the same data", () => {
const ajv = new Ajv2020({ allErrors: true, strict: false });
const validate = ajv.compile(customerInputSchema());
assert.equal(validate(sample), true);
assert.equal(Customer.safeParse(sample).success, true);
const bad = { ...sample, address: { ...sample.address, zip: "9961" }, extra: 1 };
assert.equal(validate(bad), false);
assert.ok(validate.errors!.some((e) => e.instancePath === "/address/zip" && e.keyword === "pattern"));
assert.equal(Customer.safeParse(bad).success, false);
});
test("parsing applies defaults that the output schema requires", () => {
const parsed = Customer.parse(sample);
assert.equal(parsed.tier, "free");
const ajv = new Ajv2020({ strict: false });
assert.equal(ajv.validate(customerOutputSchema(), parsed), true);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
AJV 8 JSON Schema validation with ajv-formats
Compile JSON Schema drafts with AJV 8, enable email/uri/uuid formats via ajv-formats, and return field errors from validate.errors for API payloads.
Ajv standalone validation code generated at build time
Compile JSON Schemas once with Ajv into standalone ESM validator modules, write them to disk and import them at runtime without compiling, checking errors, defaults and type coercion.
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
better-sqlite3 transactions with Zod-validated inputs
A small ledger on better-sqlite3 using db.transaction for atomic transfers, nested transactions as savepoints for all-or-nothing batches, and Zod 4 to validate inputs and rows, tested on in-memory SQLite.