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 breaks type safety with no compile error. Generating the interface 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 joins a union), arrays become T[] where the generator infers T 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 sometimes goes missing (which should be avatarUrl?: string | null) or always appears but is sometimes null. Feed the generator a few representative samples, or mark a field optional after generation if you know the API omits it under some conditions.

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

Empty arrays: an array with no elements ("tags": []) gives the generator nothing to infer the element type from, so it falls back to unknown[] or any[]. Point the generator at a sample where the array has items, or annotate it by hand afterward.

String literal unions: the generator infers a status field that holds one of "pending" | "active" | "closed" as plain string unless it sees every possible value in the sample. Even then, most generators default to the broader string type unless you opt into literal-union inference.

Dates: JSON has no date type, so timestamps arrive as strings (ISO 8601) or numbers (Unix epoch). A generator types a date field as string or number, not Date, and you still parse it yourself after fetching.

Generator options worth knowing

A handful of generator toggles change the output a lot, and half the value lies in knowing which to switch on. Optional fields: most tools can emit `avatarUrl?: string` for keys absent from some samples. readonly: `readonly id: number` documents which fields the client should treat as immutable, at every usage site, for free. Literal unions: in place of `status: string`, infer `status: "pending" | "active" | "closed"` from the observed values, so API typos become compile errors. JSDoc: descriptions attached during generation show up in editor hovers, where developers read field semantics.

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

The same idea in other languages

The inference logic (walk the tree, map types, name nested objects) stays the same for any 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[...] wrapper where needed; a Java or C# class gets typed fields and getters/setters or records. If your team works in several languages against the same API, generate types for each from the same JSON sample so they stay consistent.

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 shows only what it saw. A JSON Schema states 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), generate types from the schema instead of a sample. required drives optionality, enum drives literal unions, and descriptions become JSDoc, and you don't have to hope your sample included every variant. json-schema-to-typescript does this in the JavaScript ecosystem.

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

Keeping generated types in sync

Generated types go stale the moment the API changes and nobody regenerates them. Make generation a repeatable step instead of a one-time paste: check a real sample response into the repo (or have a script fetch it from a staging endpoint), regenerate the interface in CI, and fail the build when the output differs from what's committed. Drift then shows up as a diff you can review instead of a runtime undefined.

Two conventions help. First, don't 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, because six months later nobody will remember which file was which. When the upstream API ships a new version, bump the checked-in sample and the generated types in the same commit that migrates the client code.

Before you trust generated types

Treat generated types as a starting point and finish the contract yourself. Before committing them, confirm optional fields against the API documentation (one sample isn't enough), replace loose string fields that hold enums with literal unions, check that empty-array fields got a real element type from a better sample, and regenerate whenever the upstream API adds or renames a field instead of 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 you give it, it should infer string | number. With one sample, it can type only what it saw. That's the main reason to generate from several representative payloads instead of a single happy-path example.
Should generated interfaces use `interface` or `type`?
For this use case they behave almost the same. `interface` is more common for object shapes and supports declaration merging; `type` handles unions and intersections more flexibly. Either works for API response shapes, so match your codebase's existing convention.
How do I keep generated types in sync as the API evolves?
Make generation a repeatable step: keep a real sample response checked in (or fetch it from a staging endpoint) and regenerate the interface whenever the sample changes, instead of hand-editing the generated file. Some teams wire this into CI to catch drift.
Does this work for deeply nested or recursive JSON, like a tree structure?
Deep nesting generates nested interfaces without trouble. Recursive structures (a comment that contains replies of the same shape) need a self-referential type, which most generators won't infer from a single sample. 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 ties your build to a staging API's uptime and hides type changes from code review: a teammate updating a handler won't see that the response type shifted under them. Commit the generated file and add a CI check that regenerates and diffs it, and you get reviewable, reproducible types with drift caught for you.

Try these tools

Related articles