Skip to content
jsonforge.app
Back to blog
Guide10 min read

Generating TypeScript Types from JSON: A Practical Guide

Hand-writing a TypeScript interface for a 40-field API response is tedious and error-prone — a typo in a field name silently breaks type safety instead of throwing a compile error. Generating the interface directly from a real JSON sample is faster and more accurate, as long as you understand where inference has to guess.

How JSON-to-TypeScript inference works

A generator walks the JSON value tree and maps each JavaScript type to its TypeScript equivalent: string stays string, number stays number, boolean stays boolean, null becomes null (or is merged into a union), arrays become T[] where T is inferred from the elements, and objects become nested interfaces.

A JSON sample and its inferred interface.
json
{
  "id": 42,
  "email": "ada@example.com",
  "isActive": true,
  "roles": ["admin", "editor"],
  "profile": {
    "displayName": "Ada",
    "avatarUrl": null
  }
}
Inferred TypeScript output.
typescript
interface Root {
  id: number;
  email: string;
  isActive: boolean;
  roles: string[];
  profile: {
    displayName: string;
    avatarUrl: string | null;
  };
}

Where inference has to guess

Optional vs. always-present fields: a single JSON sample can't tell a generator whether avatarUrl is sometimes missing entirely (which should be avatarUrl?: string | null) or always present but sometimes null. Feed the generator a few representative samples, or manually mark a field optional after generation if you know the API omits it under some conditions.

Numbers that are really IDs vs. quantities: JSON has one number type, but TypeScript can't distinguish an integer ID from a floating-point price without extra context. Some generators offer branded types or literal unions for this; most just emit number and leave the distinction to you.

Empty arrays: an array with no examples inside it ("tags": []) gives the generator nothing to infer the element type from — it typically falls back to unknown[] or any[]. Point the generator at a sample where the array is populated, or annotate it manually afterward.

String literal unions: a status field that's always one of "pending" | "active" | "closed" will be inferred as plain string unless the generator sees every possible value in the sample — and even then, most generators default to the broader string type unless you opt into literal-union inference explicitly.

Dates: JSON has no date type — timestamps are always strings (ISO 8601) or numbers (Unix epoch). A generator will type a date field as string or number, not Date; you still need to parse it yourself after fetching.

Generator options worth knowing

Generators expose a handful of toggles that materially change the output, and knowing what to switch on is half the value. Optional fields: most tools can emit `avatarUrl?: string` for keys absent from some samples. readonly: emitting `readonly id: number` documents which fields the client should treat as immutable, for free, at every usage site. Literal unions: instead of `status: string`, infer `status: "pending" | "active" | "closed"` from the observed values — narrower types that turn API typos into compile errors. JSDoc: descriptions attached during generation flow through to editor hovers, which is where field semantics actually get read.

One structural choice deserves attention: whether an object with heterogeneous keys — a map of user IDs to scores — becomes an index signature `{ [key: string]: number }` or a fully enumerated interface with dozens of literal keys. Generators guess based on how uniform the values are, and they guess conservatively. Check the output for any object your API genuinely treats as a map, and fix it by hand once rather than shipping a 400-key interface.

The same idea in other languages

The inference logic — walk the tree, map types, name nested objects — is the same regardless of target language; only the output syntax changes. A Go struct gets the same field mapping with struct tags for JSON key names; a Python dataclass gets type hints and an optional Optional[...] wrapper; a Java or C# class gets typed fields and getters/setters or records. If your team works across multiple languages against the same API, generating types for each from the same JSON sample keeps them honest with each other.

The same shape as a Go struct.
go
type Root struct {
	ID       int     `json:"id"`
	Email    string  `json:"email"`
	IsActive bool    `json:"isActive"`
	Roles    []string `json:"roles"`
	Profile  struct {
		DisplayName string  `json:"displayName"`
		AvatarURL   *string `json:"avatarUrl"`
	} `json:"profile"`
}

JSON Schema as the middle layer

A JSON sample can only show what it saw; a JSON Schema can state the contract — which fields are required, what the enum values are, what format a string has. When the API publishes a schema (or you can write one), generating types from the schema instead of a sample is strictly better: required drives optionality, enum drives literal unions, and descriptions become JSDoc, all without hoping your sample happened to include every variant. json-schema-to-typescript does exactly this in the JavaScript ecosystem.

The reverse also pays off: point a schema generator at several real payloads to draft a schema, tighten it by hand, then generate the TypeScript from it. Keeping the schema as the middle layer lets you derive a TypeScript client, a Go worker, and Python scripts from one source of truth instead of three hand-maintained copies that drift apart quietly.

Keeping generated types in sync

Generated types rot the moment the API changes and nothing regenerates them. The fix is to make generation a repeatable step rather than a one-time paste: keep a real sample response checked into the repo (or fetched from a staging endpoint by a script), regenerate the interface in CI, and fail the build when the output differs from what's committed. Drift then shows up as a reviewable diff instead of a runtime undefined.

Two conventions keep this pleasant. First, never hand-edit the generated file — amend it with a separate declaration that extends or narrows the generated interface (`interface User extends UserGenerated { status: UserStatus }`). Second, put a "generated — do not edit" banner at the top of the file; six months later, nobody will remember which file was which. When the upstream API is versioned, bump the checked-in sample and the generated types in the same commit that migrates the client code.

Before you trust generated types

Generated types are a strong starting point, not a final contract. Before committing them: confirm optional fields against real API documentation (not just one sample), replace loose string fields that are actually enums with literal unions, double-check that empty-array fields got a real element type from a better sample, and re-generate whenever the upstream API adds or renames a field rather than hand-patching the interface.

FAQ

Can a JSON-to-TypeScript generator handle a field that's sometimes a string and sometimes a number?
Yes, if it sees both variants across the samples it's given — it should infer string | number. With only one sample, it can only type what it saw. This is the main reason to generate from multiple representative payloads rather than a single happy-path example.
Should generated interfaces use `interface` or `type`?
Functionally near-identical for this use case. `interface` is more common for object shapes and supports declaration merging; `type` is more flexible for unions and intersections. Either works for API response shapes — pick whichever matches your codebase's existing convention.
How do I keep generated types in sync as the API evolves?
Treat generation as a repeatable step, not a one-time copy-paste: keep a real sample response checked in (or fetched from a staging endpoint) and regenerate the interface whenever the sample changes, rather than hand-editing the generated file. Some teams wire this into CI to catch drift automatically.
Does this work for deeply nested or recursive JSON, like a tree structure?
Deep nesting generates nested interfaces fine. Truly recursive structures (a comment that can contain replies of the same shape) need a self-referential type, which most generators won't infer automatically from a single sample — you'll typically need to hand-write the recursive interface once and reuse it.
Should I commit generated type files, or generate them at build time?
Commit them. Build-time generation couples your build to a staging API's availability and makes code review blind to type changes — a teammate updating a handler won't see that the response type shifted underneath them. Committing the generated file, plus a CI check that regenerates and diffs it, gives you reviewable, reproducible types with drift caught automatically.

Try these tools

Related articles