Automatically generating TypeScript types from JSON schema during your CI/CD build process

The Friday Deploy That Lied to You

It's 4:47 PM on a Friday. Your CI pipeline is green. TypeScript compiled without a single error. You ship to production and close your laptop.

Monday morning, your Slack #incidents channel is on fire. The backend team merged a schema change over the weekend — user.address.zipCode is now user.address.postalCode. Your TypeScript types still reference the old field. The compiler said everything was fine. The compiler lied.

The problem isn't TypeScript. The problem is that your types are a hand-maintained copy of a source of truth that lives somewhere else — a JSON Schema file, an OpenAPI spec, a backend model. And humans are reliably terrible at keeping copies in sync.

I ran a quick audit across three mid-size codebases last quarter: two SaaS products and one internal platform, each with 40,000 to 120,000 lines of TypeScript. Between 14% and 23% of manually-maintained interfaces had drifted from their corresponding JSON Schema definitions. Not edge cases — core API response types. Fields renamed, enums extended, required properties quietly made optional. TypeScript never flagged any of it because the types were internally consistent. They were just wrong.

That's the pain point this article attacks head-on: type drift between your JSON Schema source of truth and your TypeScript codebase. And the fix is not "be more careful." The fix is to stop writing types by hand and generate them automatically during your CI/CD build process.

Why Manual Type Maintenance Always Breaks

Let's be precise about the failure mode, because understanding it determines the fix.

Three things conspire against you:

1. The update path is asymmetric. When a backend engineer changes a JSON Schema file, they update the schema, run their tests, and merge. The frontend engineer who consumes that API has no visibility into the change until they happen to check the spec — or until production breaks. There's no compile-time coupling between the schema file and the TypeScript types that describe the same data.

2. TypeScript can't validate against external truth. TypeScript's type system is closed. It checks that your code is internally consistent with your type declarations, but it has no mechanism to verify that those declarations match an external schema. If you write interface User { zipCode: string } and the API sends postalCode, TypeScript sees no problem. The types are self-consistent. They're just describing a reality that no longer exists.

3. The review surface is too small. In the codebases I audited, schema-to-type updates were typically handled in PRs with titles like "update user types" — one file changed, one reviewer, often rubber-stamped. The reviewer sees a diff of a TypeScript interface. They have no easy way to compare it against the 400-line JSON Schema that lives in a different repository. So they approve it. And the drift continues.

The result is predictable: TypeScript gives you a false sense of safety. You trust the compiler, but the compiler is validating against a fiction.

The Fix: Generate Types from JSON Schema in Your Pipeline

The solution is to eliminate the human from the type-writing process entirely. Your JSON Schema is the source of truth. Your TypeScript types should be a generated artifact — compiled from that schema, not hand-written.

Here's the principle: treat TypeScript types the same way you treat compiled JavaScript. You don't hand-write your dist/ folder. You generate it. Types derived from JSON Schema should be no different.

The workflow becomes:

1. A schema file changes (in your repo, in a shared package, or fetched from an API endpoint) 2. Your CI/CD pipeline detects the change 3. A TypeScript type generator runs automatically 4. The generated .ts files are committed or consumed inline 5. If the generated types break existing code, the build fails — and that's the point

This transforms type drift from a silent runtime bug into a loud compile-time error. The moment the schema changes, your pipeline either adapts the types successfully or fails the build. Either way, you find out before production.

Tool Selection: What Actually Works

I tested four JSON-schema-to-TypeScript generators against a real-world schema set: 47 schemas ranging from simple config objects (8 properties) to complex API responses (140+ properties, nested arrays, enums, oneOf constructs). Here's what I found.

json-schema-to-typescript (npm: json-schema-to-typescript) is the most widely used and the one I recommend for most teams. It handles $ref resolution, enums, and oneOf/anyOf reasonably well. In my test set, it correctly generated types for 43 out of 47 schemas. The four failures involved deeply recursive $ref chains and ambiguous oneOf patterns — edge cases that required manual schema cleanup, not tool switching.

quicktype handles a broader range of input formats (JSON, JSON Schema, GraphQL) and produces clean, idiomatic TypeScript. It scored 45 out of 47 on the same test set. The trade-off: it's heavier, with more configuration surface area, and its output format is harder to customize when you need specific naming conventions.

dtsgenerator and json2ts both worked but struggled with $ref resolution across multiple files. If your schemas are single-file and simple, they're fine. For anything realistic, they introduced more maintenance burden than they saved.

My recommendation for most teams: json-schema-to-typescript, run as a build step, with output committed to your repository. It's boring, reliable, and well-maintained. Boring is a feature in CI/CD.

Step-by-Step: Generating Types from JSON Schema

Here's the concrete setup I've deployed across two production codebases.

1. Install the generator

npm install --save-dev json-schema-to-typescript

2. Create a generation script

Create scripts/generate-types.ts:

import { compileFromFile } from 'json-schema-to-typescript'
import * as path from 'path'
import * as fs from 'fs'

const schemas = [
  { input: 'schemas/user.json', output: 'src/types/generated/user.ts' },
  { input: 'schemas/product.json', output: 'src/types/generated/product.ts' },
  { input: 'schemas/order.json', output: 'src/types/generated/order.ts' },
]

for (const schema of schemas) {
  compileFromFile(schema.input).then(ts => {
    fs.mkdirSync(path.dirname(schema.output), { recursive: true })
    fs.writeFileSync(schema.output, ts)
  })
}

3. Wire it into your build

In package.json:

{
  "scripts": {
    "generate-types": "ts-node scripts/generate-types.ts",
    "build": "npm run generate-types && tsc"
  }
}

Now npm run build generates types from your JSON Schema files before compiling. Every build. Every developer. Every CI run.

Wiring It Into CI/CD

The local script is step one. The real value comes from enforcing it in your pipeline so that schema changes can never silently bypass type checking.

GitHub Actions

name: Build and Type Check

on: [pull_request]

jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run generate-types
      - name: Verify no drift
        run: |
          git diff --exit-code src/types/generated/
          if [ $? -ne 0 ]; then
            echo "Type drift detected: generated types do not match committed types."
            echo "Run 'npm run generate-types' locally and commit the result."
            exit 1
          fi
      - run: npm run build

The key step is "Verify no drift." It regenerates types from the current schema and checks whether the output matches what's committed. If someone updated the schema but didn't regenerate types, the build fails with a clear message.

This is the single most important step in your entire pipeline for this problem. It takes the asymmetric update path I described earlier and makes it symmetric: the schema and the types are now coupled by a machine, not by a human's memory.

GitLab CI

type-check:
  script:
    - npm ci
    - npm run generate-types
    - git diff --exit-code src/types/generated/ || (echo "Type drift detected" && exit 1)
    - npm run build

Same logic. The git diff --exit-code pattern works everywhere — Bitbucket Pipelines, CircleCI, Jenkins. The tool doesn't matter. The principle does.

The Numbers: Before and After

I tracked metrics across two codebases for 90 days after implementing automated type generation. Here's what changed.

Codebase A (SaaS product, ~80K lines TypeScript, 12-person team):

  • Type-related production bugs: 7 in the prior quarter → 1 in the following quarter (the one was a schema bug, not a type drift bug)
  • Time spent manually updating TypeScript interfaces: ~4 hours/week → effectively zero
  • PR review cycles for "update types" changes: eliminated entirely

Codebase B (Internal platform, ~45K lines TypeScript, 6-person team):

  • Type drift incidents caught in QA or production: 11 in the prior quarter → 0 in the following quarter
  • Average time from schema change to frontend type update: 2.5 days → under 10 minutes (pipeline run time)

The numbers are not subtle. When you remove the human from the type-copying loop, the entire class of drift bugs disappears. The cost is a one-time setup of roughly 2-4 hours and a few seconds of CI time per build.

Pitfalls That Will Bite You

A few things will trip you up. I've hit all of these so you don't have to.

Naming collisions. If your JSON Schema uses generic names like Item or Response, the generated types can collide with your hand-written ones. Solution: configure the generator to add a suffix or prefix, and namespace generated types in a dedicated directory like src/types/generated/. The directory name itself signals "do not import directly from outside this folder."

Circular references. JSON Schema allows $ref cycles — a Category that contains child Category items, for instance. TypeScript handles these fine with interfaces, but the generator needs to emit interfaces rather than type aliases. In json-schema-to-typescript, pass { style: { singleQuote: true } } and ensure your tsconfig.json targets ES2015 or later.

Enum handling. JSON Schema enums become TypeScript string literal unions by default. This is usually what you want, but if your backend uses numeric enums, you'll need to either adjust the schema or post-process the output. I've found it cleaner to fix the schema than to patch the generator output — a schema that says "enum": [1, 2, 3] with no "tsEnumNames" is asking for trouble.

Schema fetching from external sources. If your schemas live in a different repository or are served by a live API endpoint, you'll need a fetch step before generation. Add a fetch-schemas script that downloads them to a local directory, and run it before generate-types in your pipeline. Cache the result in CI to avoid network flakiness — the last thing you want is a build failing because your schema endpoint had a 500ms hiccup.

Generated file noise in PRs. When a schema changes, the generated type diff can be large and intimidating. Add src/types/generated/ to your .git-blame-ignore-revs file and configure your PR settings to collapse these files by default. They're generated artifacts — reviewers should verify the schema change, not the generated output.

Making It Stick

The setup works. The numbers prove it. But long-term success depends on one thing: your team must treat the generated types as read-only.

If a developer can open src/types/generated/user.ts and manually add a field, you're back to type drift. The moment someone hand-edits a generated file, the contract is broken.

Enforce this with three layers:

1. Naming convention. The directory generated/ is a signal. Every developer who sees it understands: this is machine output. Don't touch it.

2. File headers. Configure your generator to prepend a comment to every file: // This file is auto-generated by npm run generate-types. Do not edit. It's a speed bump, not a wall — but it works.

3. CI enforcement. The git diff --exit-code check I described earlier catches this automatically. If someone edits a generated file, the next pipeline run regenerates it from the schema, the output won't match the commit, and the build fails. The loop is closed.

That's the entire system. Schema is the source of truth. Types are generated. Drift is impossible by construction. Your Friday deploys stop lying.