JSON Toolkit

Two-Way Sync Between Schema and TypeScript Types

Bottom line: Types and schemas each cover one segment: one at compile time, one at runtime. The question isn't which to pick, but how to keep the two in sync automatically.

Intermediate~11 min readPublished 2026-10-30Updated 2026-10-30
Cover for Two-Way Sync Between Schema and TypeScript Types

Bottom line: pick one source of truth first — schema-first when the backend owns the contract, zod-style types-first when the frontend does — then generate the other copy and diff it in CI; hand-write only semantics the generator cannot express, such as branded types.

If you hand-write both definitions, they will drift. The price of drift is "it compiles, then returns a 500 at runtime."

1. Why keeping only one copy doesn't work

Capability TypeScript types JSON Schema
Compile-time checking ✓ ✗
Runtime validation ✗ (types are erased) ✓
Cross-language use ✗ ✓ (Java/Go/Python can all read it)
IDE completion and refactoring ✓ So-so
Documentation generation So-so ✓ (the OpenAPI ecosystem)

The input to an API comes from the network and is always unknown, so runtime validation cannot be skipped; and while writing business code you still need type completion. The sensible approach is to choose one as the source of truth and generate the other.

2. Path A: schema first

Suited to: backend-driven work, cross-language use, and public contracts.

pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';

/**
 * Generate TypeScript type definitions from a JSON Schema.
 * @param schemaPath Path to the schema file
 * @param outputPath Path to the output file
 * @returns A Promise that resolves when generation is finished
 */
async function generateTypes(schemaPath: string, outputPath: string): Promise<void> {
  const typeText = await compileFromFile(schemaPath, {
    bannerComment: '/* Auto-generated from the schema. Do not edit by hand. */',
    additionalProperties: false,
    style: { singleQuote: true },
  });
  writeFileSync(outputPath, typeText, 'utf8');
}

A few conventions in the output are worth noting:

  • required: ["id"] → id: string; keys not listed in required → id?: string;
  • "type": ["string", "null"] → string | null;
  • enum → a union of literal types, far more precise than string;
  • additionalProperties: false determines whether an index signature is generated.

There is also a zero-build option, json-schema-to-ts, which infers directly at the type level:

import type { FromSchema } from 'json-schema-to-ts';

const userSchema = {
  type: 'object',
  properties: { id: { type: 'string' }, age: { type: 'integer' } },
  required: ['id'],
  additionalProperties: false,
} as const;

type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }

The upside is no generation step and types that are always in sync with the schema; the downside is that the schema must be written as as const, and complex $ref scenarios compile slowly.

3. Path B: types first

Suited to: frontend-driven work with libraries such as zod / valibot, where "the schema is the type".

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

export const userSchema = z.object({
  id: z.string().min(1),
  name: z.string().min(1).max(64),
  age: z.number().int().min(0).max(150).optional(),
  tags: z.array(z.string()).default([]),
});

export type User = z.infer<typeof userSchema>;

/** The contract published to other languages */
export const userJsonSchema = zodToJsonSchema(userSchema, {
  target: 'jsonSchema2020-12',
  $refStrategy: 'none',
});

Here zod plays both roles — "type definition" and "runtime validation" — and JSON Schema is merely an export artifact. This is the least effort overall, but keep two things in mind:

  • z.infer and the generated JSON Schema do not map exactly for default, transform, and refine;
  • The generated schema may contain $ref and definitions, so check before publishing whether it matches the target specification.

4. The reverse direction needs checking too

Whichever path you choose, add a CI check: the generated artifacts must match the files in the repository.

# Generate into a temp directory and compare; fail CI on any difference
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts

Without this step, three months later nobody knows whether api.d.ts is current.

5. Four high-frequency traps

5.1 optional and nullable are not the same thing

type A = { name?: string };        // the key may be absent
type B = { name: string | null };  // the key must exist, but the value may be null

In schema terms:

{ "properties": { "name": { "type": "string" } } }                        // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable

Generators handle the two cases completely differently. The contract must state which one you mean; otherwise the front end's data.name?.length will crash on null.

5.2 Evolving enums

type Status = 'pending' | 'paid' | 'refunded';

If the backend adds 'cancelled' and the frontend types are not updated, the code falls into the default branch. Enum-like fields should handle unknown values explicitly:

function normalizeStatus(raw: string): Status | 'unknown' {
  return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}

5.3 Dates and money cannot simply use string

In the schema, format: "date-time" is only an annotation; in TypeScript it is just string. When you need a stronger guarantee, use a "branded type":

type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };

/**
 * Mark a string as an ISO timestamp.
 * @param value A string whose format has already been verified
 * @returns The branded timestamp string
 */
function asIsoDateTime(value: string): IsoDateTime {
  return value as IsoDateTime;
}

5.4 Large integers

type: "integer" is not equivalent to number once values exceed 2^53. Write it in the contract as:

{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }

6. A suggested rollout process

  1. Define the contract: start by writing the request/response structure as a schema (or zod);
  2. Generate types: generate .d.ts in CI and commit it to the repository so differences are easy to review;
  3. Generate validators: compile the same schema into an Ajv validation function, and call it at every server entry point;
  4. Generate documentation: emit Markdown or OpenAPI from the schema so the docs never go stale by hand;
  5. Regression: run API fixtures through both "type checking" and "schema validation"; only passing both counts.

7. Checklist

  • [ ] There is exactly one source of truth; the other copy is generated by a script;
  • [ ] The generation script runs in CI, and any artifact difference fails the build;
  • [ ] optional and nullable are explicitly distinguished in the contract;
  • [ ] Enum fields have a fallback for unknown values;
  • [ ] Large integers, money, and time have explicit conventions in both the schema and the types;
  • [ ] Generated type files carry a "do not edit by hand" header.

FAQ

How do I decide between schema-first and types-first?

Choose schema-first when the backend owns the contract, needs cross-language use, or publishes the contract publicly, and generate types with json-schema-to-typescript. Choose a zod-style "schema is the type" library when the frontend leads and wants types and validation defined once, then export JSON Schema. Either path needs a CI step that compares generated artifacts with the repository files.

Why do hand-written type and schema definitions always drift?

Types are erased at compile time while schemas only take effect at runtime, and the two are edited by different people at different times. Without generation and comparison, one copy is updated while the other stays still, which produces "it compiles, then returns a 500 at runtime".

Should I commit the generated type files to the repository?

Yes. Committing them lets reviewers see contract changes, and CI can compare fresh generation output against the repository files and fail on any difference. Leave the artifacts only in a local folder or build cache and nobody will know three months later whether they are current.