JSON to TypeScript Types: Generate Interfaces Automatically
Every backend integration starts the same way: you open the API docs, look at a 50-field JSON response, and start typing interface User {. You copy field names, guess whether createdAt is a string or a number, wonder if address can be null, and ship an interface that silently drifts from reality the moment the backend adds a field. Type generators eliminate this manual step. Paste in a JSON sample — or feed it a URL — and get a complete, accurate TypeScript interface in under a second. Use the JSON to Code converter to follow along with every example in this article.
How Type Generators Work
Type generators perform structural inference: they walk the JSON object tree and map each value to a TypeScript type based on what they observe.
{
"id": 42,
"name": "Alice",
"verified": true,
"score": 9.8,
"tags": ["admin", "beta"],
"address": null
}That sample produces:
export interface User {
id: number;
name: string;
verified: boolean;
score: number;
tags: string[];
address: null;
}The inference rules are straightforward: JSON numbers become number, strings become string, booleans become boolean, arrays become typed arrays, and nested objects become nested interfaces. The tricky part is what happens when the generator sees multiple samples of the same type — it merges them with union types.
Feed two samples where score is sometimes a number and sometimes a string:
// Sample 1
{ "score": 9.8 }
// Sample 2
{ "score": "N/A" }export interface User {
score: number | string;
}This union behavior is why feeding multiple real API responses to the generator produces more accurate types than relying on a single example. A single sample can make optional fields look required, or nullable fields look non-null.
The Nullable vs Optional Pitfall
This is the most common source of inaccurate generated types. TypeScript draws a sharp distinction between three different states:
| JSON | TypeScript type | Meaning |
|---|---|---|
"field": "value" | field: string | Always present, never null |
"field": null | field: string | null | Present in the object, but value is null |
| key absent from object | field?: string | Key may not exist at all |
| key absent or null | field?: string | null | Key may be absent or present with null value |
A generator looking at one sample where address is null will emit address: null — which is technically correct for that sample but useless in practice. The real type should be address: Address | null.
Conversely, a field that is simply absent in one sample but present in another will be marked optional (field?: string) when fed both samples. This is the correct behavior — but it only works if you give the generator enough samples to observe both states.
Rule: Always provide at least two samples: one where every optional field is present, and one where optional fields are absent or null. The generator merges the observations into the most accurate possible type.
Array Handling
Arrays introduce their own inference challenges:
{
"items": [],
"tags": ["alpha", "beta"],
"mixed": [1, "two", true]
}export interface Response {
items: never[]; // empty array — no element type observed
tags: string[]; // homogeneous string array
mixed: Array<number | string | boolean>; // union from mixed types
}The never[] type for an empty array is technically correct — there are no elements to infer from — but it is almost always wrong in practice. When you see never[], replace it manually with the expected element type. Some generators (like quicktype) emit any[] instead, which is more permissive but equally imprecise.
For homogeneous arrays of objects, the generator creates a named interface for the element type and references it:
{
"users": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]
}export interface Root {
users: User[];
}
export interface User {
id: number;
name: string;
}Recursive Types
Tree structures and nested comment threads produce recursive type definitions — interfaces that reference themselves. Most generators handle shallow recursion correctly:
{
"id": 1,
"text": "Parent comment",
"replies": [
{
"id": 2,
"text": "Child comment",
"replies": []
}
]
}export interface Comment {
id: number;
text: string;
replies: Comment[];
}TypeScript handles self-referential interfaces natively, so this output is immediately usable. Problems arise with deep mutual recursion (type A references type B which references type A) and with generators that detect the recursion but give up, emitting any instead. If you see any in a generated type, check whether it is covering a recursive reference and supply the correct type manually.
Quicktype Walkthrough
Quicktype is the most capable open-source type generator available. Install it globally:
npm install -g quicktypeGenerate TypeScript interfaces from a local JSON file:
quicktype user.json -o User.ts --lang typescriptGenerate directly from a URL (quicktype fetches the response and infers types from it):
quicktype https://api.example.com/users/1 -o User.ts --lang typescriptFeed multiple samples for more accurate union types. Pass them as separate files or via stdin:
# Multiple files — quicktype merges all samples
quicktype sample1.json sample2.json sample3.json -o User.ts --lang typescript
# From a JSON Lines file (one JSON object per line)
quicktype --src-lang json-lines responses.jsonl -o User.ts --lang typescriptQuicktype also generates a Convert module with parse and stringify helpers by default. To get bare interfaces only:
quicktype user.json -o User.ts --lang typescript --just-typesThe --nice-property-names flag converts snake_case JSON keys to camelCase TypeScript properties and adds a /* original: snake_case */ comment for traceability.
Alternative Approaches
Quicktype is not the only option. Each tool has a different tradeoff between automation and control:
- json-to-ts (npm) — lightweight Node.js library, easy to embed in scripts or build tools. Less accurate than quicktype for edge cases but zero config.
- ts-morph — TypeScript AST manipulation library. Lets you generate or transform types programmatically with full control over output formatting. Best when you need to post-process generated types (add decorators, rename fields, inject JSDoc).
- Zod schema inference — instead of generating TypeScript interfaces directly, generate a Zod schema. You get both the type and runtime validation in one step. See the JSON Formatter Guide for JSON validation patterns that pair well with Zod.
- OpenAPI / JSON Schema generators — if your API has an OpenAPI spec, tools like
openapi-typescriptgenerate types directly from the spec rather than from runtime samples. More reliable for large APIs.
Target Languages
| Language | Output type | What quicktype handles well | Watch out for |
|---|---|---|---|
| TypeScript | interfaces + type aliases | Union types, optional fields, generics | Empty arrays need manual annotation |
| Go | structs with json tags | json:"field,omitempty", pointer types for nullable | No union types — uses interface{} |
| Python | dataclasses or TypedDict | Optional[str], nested dataclasses | Runtime validation still manual |
| Rust | structs with serde derives | Option<T>, #[serde(rename)] | Mixed-type arrays require enum variants |
| Java | classes with Jackson annotations | @JsonProperty, nullable via @Nullable | Verbose; consider Kotlin data classes instead |
| C# | classes with Newtonsoft / System.Text.Json | JsonPropertyName, nullable reference types | Enable #nullable enable for best results |
Try all these targets in the browser with the JSON to Code converter — select the output language from the dropdown and the types update instantly.
Runtime Validation
TypeScript types are erased at compile time. An interface says nothing about what arrives over the wire at runtime. If the API changes a field type, your code will silently receive the wrong shape — no TypeScript error, no runtime exception, just wrong data flowing through your application.
The solution is to add a validation layer at the API boundary. The most popular options:
// Zod — define schema once, derive type from it
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
verified: z.boolean(),
score: z.number(),
tags: z.array(z.string()),
address: z.string().nullable(),
});
type User = z.infer<typeof UserSchema>;
// Parse and validate at the API boundary
const user = UserSchema.parse(await response.json());// Valibot — similar API, smaller bundle
import { object, number, string, boolean, array, nullable, parse } from "valibot";
const UserSchema = object({
id: number(),
name: string(),
verified: boolean(),
score: number(),
tags: array(string()),
address: nullable(string()),
});
type User = InferOutput<typeof UserSchema>;Zod can also be generated from a JSON sample using the json-to-zod CLI:
npx json-to-zod -s user.json -o UserSchema.tsFor validation-heavy pipelines, consider AJV (JSON Schema validator) with generated JSON Schema definitions — AJV compiles schemas to fast validation functions, making it suitable for high-throughput API servers.
See the API debug workflow guide for patterns around catching these mismatches early during development.
Recommended Workflow
A repeatable workflow for integrating a new API endpoint into a TypeScript project:
- Collect samples. Fetch at least two real responses — one "happy path" (all fields populated) and one edge case (optional fields absent, arrays empty, nullable fields null).
- Generate types. Run quicktype (or use the JSON to Code converter in the browser) against both samples simultaneously.
- Audit the output. Look for
never[],any, and barenulltypes. Each signals a gap that needs a real-world sample or manual annotation. - Add runtime validation. Wrap the generated interface in a Zod schema (or equivalent) at the API fetch boundary.
- Check types into the repo. Treat generated types as source code — commit them alongside your other TypeScript files. Do not generate at build time unless the API has a machine-readable spec (OpenAPI, GraphQL schema) to generate from deterministically.
- Regenerate when the API changes. Keep the sample JSON files in the repo alongside the generated types so teammates can regenerate with a single command.
# One-liner to refresh types from saved samples
quicktype samples/user-full.json samples/user-partial.json \
-o src/types/User.ts --lang typescript --just-typesSkip the manual typing. Paste your JSON into the JSON to Code converter and get TypeScript interfaces, Go structs, Python dataclasses, Rust serde types, and more — all in one click, all processed locally in your browser.