Understanding JSON Schema → Zod
Two ways to say the same shape — language-agnostic versus TypeScript-native.
What JSON Schema buys you, why Zod won on the frontend, and the gotchas when translating between them.
JSON Schema — the spec.
JSON Schema (draft 2020-12) is a JSON document that describes the shape of other JSON documents. It's language-agnostic — OpenAPI uses it for request bodies, AsyncAPI uses it for message payloads, AJV validates against it in any JS runtime, equivalent validators exist in Python, Go, Rust, Ruby. The runtime validation is its job; type generation tools (json-schema-to-typescript, datamodel-code-generator) turn it into static types.
Zod — TypeScript-native.
Zod (Colin McDonnell, 2020) is a TypeScript-first validation library where the schema is also the type. z.object({ name: z.string() }) defines both a runtime validator and a compile-time type. z.infer<typeof Schema>extracts the static type. No code generation, no separate .json file, no out-of-sync moments. Won the frontend ecosystem essentially overnight because of this single property.
The translation table.
JSON Schema "type": "string" → Zod z.string()."minLength": 3 → .min(3)."enum": ["a","b"] → z.enum(["a","b"])."oneOf": [...] → z.union([...])."type": "array", "items": {...} → z.array(z.X())."properties": {...}, "required": [...] → z.object({...})plus .optional() on non-required fields. The translation is straightforward for ~95 % of schemas.
The gotchas.
Refs. JSON Schema's $ref is hierarchical and can be circular; Zod uses lazy schemas with explicit recursion. Tuples. JSON Schema's tuple form (items as array) maps to z.tuple([...]), not z.array(). Additional properties. JSON Schema's "additionalProperties": false maps to Zod's.strict(). Format strings (date-time, email, uuid) map to Zod's named checks (.datetime(), .email(), .uuid()) but the definitions don't always agree on edge cases.
A worked translation.
JSON Schema: {"type":"object","properties":{"id":{"type":"string","format":"uuid"},"age":{"type":"integer","minimum":0}},"required":["id"]} → Zod: z.object({ id: z.string().uuid(), age: z.number().int().min(0).optional() }). One JSON object becomes one TS literal; the type inferred from it matches what a JSON Schema → TypeScript codegen tool would produce. The Zod version runs the validator at runtime for free.
object → z.object
schema → schema
Direct field-by-field translation.
properties + required + format → z.object + optional + .uuid
= Same shape, native TS
Which to keep on your boundary.
If your API has external consumers in many languages, keep JSON Schema as the source of truth; generate Zod (and TypeScript types) from it. If your API is TypeScript-only end-to-end (tRPC, Next.js server actions), keep Zod as the source of truth; generate JSON Schema from it for documentation. Don't try to maintain both by hand — they will drift, and the drift is exactly the bug you're trying to prevent.