Skip to content

JSON to TypeScript in Practice

Generate TypeScript types from JSON samples — handling optional fields, nullable, unions, nested types — plus runtime validation strategies.

When you call an API that returns JSON, TypeScript needs a type for the result. Writing that type by hand for a 40-field response with five nested objects is error-prone and tedious. Generating it from a sample takes seconds. This guide covers the practical workflow: from sample to type, where generators get things wrong, and how to add runtime validation so your types aren't lies.

Why generate types from JSON

Two reasons to skip the hand-written interface:

  • Coverage — a generator sees every field in the sample, including the ones you'd skim past.
  • Drift — when the API adds a field, regenerating is a 5-second operation. Hand-written types rot.

The trade-off — generators infer types from one sample. If the sample doesn't include a nullable field as null, the generator marks it non-nullable. You always need to review the output.

From a sample to an interface

Given this JSON:

{
  "id": "user_123",
  "email": "ada@example.com",
  "age": 36,
  "active": true,
  "tags": ["admin", "founder"],
  "address": {
    "street": "10 Downing",
    "city": "London"
  }
}

A reasonable generated type:

interface User {
  id: string;
  email: string;
  age: number;
  active: boolean;
  tags: string[];
  address: Address;
}

interface Address {
  street: string;
  city: string;
}

Tools that do this:

Handling optional and nullable fields

A generator sees one sample. Two things you almost always have to fix by hand:

Optional fields. If a field is present in some responses and absent in others, you want field?: T, not field: T. Feed the generator multiple samples — most tools merge them and infer optionals from fields that aren't in every sample.

Nullable fields. A field that can be null should be typed T | null:

interface User {
  // present sometimes, absent sometimes
  middleName?: string;
  // present always, sometimes null
  deletedAt: string | null;
  // can be missing OR null
  avatarUrl?: string | null;
}

The three flavours are subtly different and TypeScript is strict about the difference. When in doubt, look at the API documentation, not the sample.

Unions from heterogeneous arrays

When an array contains objects of different shapes, the generator emits a union:

{
  "events": [
    { "kind": "click", "target": "button-1" },
    { "kind": "page-view", "path": "/home" }
  ]
}
type Event =
  | { kind: "click"; target: string }
  | { kind: "page-view"; path: string };

The literal type "click" (rather than string) makes this a discriminated union — TypeScript can narrow based on kind:

function handle(event: Event) {
  if (event.kind === "click") {
    event.target; // typed as string
  } else {
    event.path; // typed as string
  }
}

If your generator emits kind: string instead of kind: "click", tighten by hand. The discriminator is almost always worth narrowing.

Naming and nested types

Generators name nested types from the parent field — User.address becomes Address, Order.shippingAddress becomes ShippingAddress. Two issues:

  • Collisions — two unrelated Address types in different parts of the document. Rename one (BillingAddress, ShippingAddress).
  • Inline vs named — for tiny one-off types, inline is fine. For anything reused across multiple endpoints, give it a name and export.

The /json/types/typescript tool lets you set a root name and emits all nested types alphabetically.

as const for literal-typed fixtures

If your JSON is a fixture (test data, configuration) and you want TypeScript to know the exact values, not just the types, use as const:

const config = {
  env: "production",
  retries: 3,
  features: ["billing", "auth"],
} as const;
// type: { readonly env: "production"; readonly retries: 3; readonly features: readonly ["billing", "auth"] }

This is useful for switch statements and exhaustiveness checks.

Runtime validation vs compile-time types

A generated type is a compile-time contract. At runtime, the API might return anything. If you cast and trust:

const user = JSON.parse(text) as User; // ← a lie

…you'll get cryptic errors later when user.email is undefined.

The robust pattern is to define the shape once in a runtime validator and derive the TypeScript type from it. With Zod:

import { z } from "zod";

const User = z.object({
  id: z.string(),
  email: z.string().email(),
  age: z.number().int(),
  active: z.boolean(),
  tags: z.array(z.string()),
  address: z.object({
    street: z.string(),
    city: z.string(),
  }),
});

type User = z.infer<typeof User>;

function parseUser(text: string): User {
  return User.parse(JSON.parse(text)); // throws if shape is wrong
}

Similar libraries:

  • Yup — older, more Joi-like API.
  • io-ts — fp-ts ecosystem; more verbose, more powerful.
  • Valibot — smaller bundle than Zod, similar API.
  • @sinclair/typebox — JSON Schema-first; emits valid JSON Schema.

For a deeper look at parsing safely, see safely parsing JSON in JavaScript.

Keeping types in sync with the API

A few options, from cheapest to most reliable:

  • Manual — paste a sample, regenerate, commit. Fine for small or rarely-changing APIs.
  • Schema-driven — if the API publishes JSON Schema or OpenAPI, generate types from that with openapi-typescript or json-schema-to-typescript. The schema is the contract; the type is derived.
  • End-to-end — tRPC, GraphQL Code Generator, or sharing types between server and client packages. The compiler enforces the contract at both ends.

Match the strategy to the cost of drift. For a public REST API you don't own, manual regeneration is usually fine. For an internal API your team owns, schema-driven or shared types pay back the setup.

Generate yours

Paste a JSON sample into the JSON to TypeScript tool to get types in TypeScript, Go, Python, Rust, Java, Swift, Kotlin, or C#. Combine with a runtime validator for the production-safe pattern.

Next steps