Skip to content
yutils

JSON Schema vs TypeScript Types — When to Generate Which from the Other

What JSON Schema can do that TypeScript can't (and vice versa), generation strategies in both directions, runtime validation with Ajv/Zod, and the right boundary patterns.

~9 min read

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

AxisTypeScriptJSON Schema
When it runsCompile time onlyRuntime (Ajv, Hyperjump, etc.)
ExpressivenessUnions, generics, mapped types, conditional typesStructure + constraints (min/max, format, pattern)
Constraint checksShape only (can't say "must be positive")minimum, maxLength, format: email, etc.
LanguagesTS / JS onlyEvery language (Python, Java, Go, Rust, ...)
Toolingtsc, IDE intellisenseAjv (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 invalid

Pros:

  • 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-schema exists 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 ApiResponse

Ajv'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-schema when 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.
Back to guides