Common team question: should JSON Schema be the source of truth and TS types fall out of it, should TS be the source and the schema be a byproduct, or should both be maintained side by side? This guide covers what each does well, the trade-offs in either direction, where Ajv/Zod fit at runtime, and how to nail down API boundaries.
What's actually different
| Axis | TypeScript | JSON Schema |
|---|---|---|
| When it runs | Compile time only | Runtime (Ajv, Hyperjump, etc.) |
| Expressiveness | Unions, generics, mapped types, conditional types | Structure + constraints (min/max, format, pattern) |
| Constraint checks | Shape only (can't say "must be positive") | minimum, maxLength, format: email, etc. |
| Languages | TS / JS only | Every language (Python, Java, Go, Rust, ...) |
| Tooling | tsc, IDE intellisense | Ajv (validation), generators, OpenAPI integration |
The fundamental trade-off
TS types live only at compile time. They can't tell you at runtime whether a JSON payload from outside actually matches. JSON Schema is the opposite — runtime is its day job, but the IDE and compiler don't know about it.
The real-world question: how do I narrow data crossing an API boundary into a TS type, safely? You need a bridge between the two systems.
Option A — JSON Schema as source (OpenAPI environments)
Backend or external API publishes OpenAPI or JSON Schema. Treat the existing schema as truth and generate TS types.
Tools:
openapi-typescript— OpenAPI YAML/JSON →.d.ts.json-schema-to-typescript— pure JSON Schema → TS interfaces.quicktype— multi-language generation, also accepts JSON samples.
Pro: schema changes surface as TS compile errors, keeping the backend and frontend in sync.
Con: TS's expressive features get lost. A branded ID type (type ID = string & {__brand: "UserId"}) can't be represented in JSON Schema.
Option B — TypeScript as source (TS-first backends)
Full TS stacks (tRPC, Next.js Route Handlers) or TS-first APIs. Libraries like Zod, io-ts, or Valibot define the type and runtime validation together.
import {z} from "zod";
const User = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0),
});
type User = z.infer<typeof User>; // TS type derived
// Runtime validation
const parsed = User.parse(input); // throws on invalidPros:
- One source of truth — types and runtime checks can't drift.
- Full TS expressiveness — branded types, narrowed unions, etc.
- Schemas are code: refactoring, search, tests are all the usual workflow.
Cons:
- Hard to share with other languages.
zod-to-json-schemaexists but loses some Zod expressiveness. - Non-TS clients (mobile, third parties) still need a separate schema eventually.
Option C — Start from a JSON sample (the most common in practice)
Receive an external API response, generate types fast, ship. Source of truth is the actual response, not a schema — fastest path to the first version.
Tools:
- JSON → TypeScript — JSON in, TS interfaces out. Nested objects become separate interfaces. Zero dependencies, runs in the browser.
- JSON Schema Generator — same JSON, generates a JSON Schema (Draft 2020-12). Infers types, required fields, nesting.
Workflow: response JSON → generate both TS interface and JSON Schema. Use the TS in code, use the schema for validation and documentation.
Runtime validation — Ajv and Zod
Ajv (JSON Schema)
The fastest JSON Schema validator. Compiles a schema into a validation function (one compile, many calls).
import Ajv from "ajv";
const ajv = new Ajv();
const validate = ajv.compile({
type: "object",
properties: {
email: {type: "string", format: "email"},
age: {type: "integer", minimum: 0}
},
required: ["email"]
});
if (!validate(data)) console.error(validate.errors);Supports up to Draft 2020-12. JSON Schema Validator uses Ajv in the background to validate live.
Zod / Valibot (TS-first)
Schema is TS code. The pattern shown above. Strong IDE support, weaker exportability to other languages.
Valibot has a similar API with better tree-shaking and a smaller bundle. Worth evaluating for new 2026 projects.
API boundary — the safe pattern
Any data from outside must be runtime-validated before becoming a TS type.
Zod (Next.js Route Handler)
const Body = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function POST(req: Request) {
const json = await req.json();
const parsed = Body.safeParse(json);
if (!parsed.success) {
return Response.json({error: parsed.error.format()}, {status: 422});
}
const {email, password} = parsed.data; // safe TS type
// ...
}Ajv with an external schema
import schema from "./api-response.schema.json";
import type {ApiResponse} from "./api-response";
const validate = ajv.compile<ApiResponse>(schema);
const data = await fetchSomething();
if (!validate(data)) throw new Error("Invalid response");
// data is now narrowed to ApiResponseAjv's generic type-guard pattern. When validate returns true, TS narrows data to ApiResponse.
Common pitfalls
1. TS types without runtime checks
fetch().then(r => r.json() as User) is a lie. If the runtime shape differs, you fail silently. External data needs validation, full stop.
2. Maintaining two schemas by hand
TS interface here, JSON Schema there — drift starts and is hard to spot. Generate one from the other (option A or B), or derive both from one definition (Zod + zod-to-json-schema).
3. JSON Schema's default additionalProperties: true
If you don't say otherwise, extra fields are allowed. To detect unknown fields, set additionalProperties: false explicitly.
4. Escaping into any / unknown
Casting to any defeats the point of validation. unknown + a validation function is the right answer.
5. Branded types in JSON Schema
Patterns like UserId & Brand have no direct equivalent in JSON Schema. Hand-cast after validation, or use Zod's z.brand().
Decision guide
- Multi-language backend with OpenAPI → option A (schema as source, generate TS).
- Full TS stack → option B (Zod). Use
zod-to-json-schemawhen you need to publish. - Quick external API integration → option C (start with json-to-ts or quicktype, layer on validation).
- Storage (DB) and wire format together → Drizzle/Prisma + Zod or TypeBox.
Summary
- TS = compile-time shape. JSON Schema = runtime validation + cross- language.
- Pick one source of truth. Auto-derive both, or generate one from the other.
- Don't trust external data without runtime validation (Ajv/Zod).
- Useful tools: JSON → TypeScript, JSON Schema Generator, JSON Schema Validator.
- New TS projects → Zod/Valibot. Multi-language platforms → OpenAPI.