Converting JSON to TypeScript gives your API responses real types. Every fetch call returns any or unknown until you tell TypeScript what shape to expect. Writing those interfaces by hand for a deeply nested response is slow and easy to get wrong, so many teams generate them from a JSON sample.
This guide shows how the conversion works. Nested objects become nested interfaces, arrays map to T[], optional properties are detected across array items, and mixed value types become unions. It then compares an online generator, the quicktype CLI and manual typing. It ends with the one thing generated types cannot do: validate data at runtime.
Why typing API responses matters
An untyped API response hides bugs. When await res.json() returns any, every property access compiles, even when the field is misspelled, missing or the wrong type. The mistake shows up at runtime as Cannot read properties of undefined, usually in production and far from the fetch that caused it.
Typing the response reverses that. Once the shape is declared, the compiler catches user.emial at build time, autocomplete shows every field, and renaming a property updates the whole codebase safely. For a nested payload, autocomplete on data.results[0].profile.avatarUrl beats guessing at it and debugging later.
- Compile-time safety: typos and shape mismatches fail the build, not the user's browser.
- Editor autocomplete and inline docs for every field of the response.
- Safe refactoring: renaming a field updates every call site through the type system.
- Self-documenting code: the interface is the contract, readable without opening the API docs.
Writing these interfaces by hand for a real API, with pagination wrappers, nested relations and arrays of objects, is slow and easy to get wrong. Generating them from a real sample response removes the guesswork.
From a JSON sample to TypeScript interfaces
The core mapping is mechanical. A JSON object becomes an interface, each key becomes a property, and each value's type maps to a TypeScript type: strings to string, numbers to number, booleans to boolean. The interesting cases are the recursive ones: nested objects and arrays.
Nested objects become named interfaces
When a value is itself an object, a good generator pulls it out into its own named interface instead of inlining an anonymous type. The output stays readable, and you can reference the sub-type elsewhere. Take this sample response:
{
"id": 42,
"name": "Ada Lovelace",
"email": "ada@example.com",
"isActive": true,
"roles": ["admin", "editor"],
"profile": {
"bio": null,
"avatarUrl": "https://example.com/a.png"
},
"posts": [
{ "id": 1, "title": "Hello", "tags": ["intro"] }
]
}The conversion produces three interfaces: the root object plus one for each nested object shape. Note bio. The sample only shows null, so a generator can infer null or any at best. The string | null below is what you would change it to by hand once you know the field is a nullable string.
interface User {
id: number;
name: string;
email: string;
isActive: boolean;
roles: string[];
profile: Profile;
posts: Post[];
}
interface Profile {
bio: string | null;
avatarUrl: string;
}
interface Post {
id: number;
title: string;
tags: string[];
}Arrays map to T[]
An array of strings becomes string[] (roles above), and an array of objects becomes an array of the extracted element interface (posts: Post[]). The generator looks at the array's elements to infer T. That is also where optional properties are detected, as covered next.
Optional properties, nulls and unions
Real JSON is rarely uniform. A field can be present in one object and missing in another, or hold a string here and a number there. How a generator handles this decides whether you get a usable type or one you have to rewrite.
Optional properties detected across array items
A single object cannot tell you whether a field is optional, because every key it has is present. You only see optional fields across several samples. When a generator sees an array of objects, it collects every key and marks any key missing from at least one element with ?:
{
"items": [
{ "id": 1, "name": "Keyboard", "discount": 0.1 },
{ "id": 2, "name": "Mouse" }
]
}interface Item {
id: number;
name: string;
discount?: number;
}
interface Root {
items: Item[];
}That is why a single-object sample often produces types with no optional fields. There is nothing to compare against. An array with realistic variation gives the generator the evidence it needs.
Null values and unions for mixed types
A JSON null on its own cannot be typed precisely, because it says nothing about what the field holds when it is not null. Generators handle it in one of two ways. If null is all they see, they emit null or any. If the same field is sometimes null and sometimes a value, they produce a union like string | null.
The same applies when a field holds different primitive types across samples. If id appears as both a number and a string, a good generator emits a union instead of quietly picking one:
interface DataRecord {
// "id": 1 in one sample, "id": "a1" in another
id: number | string;
// present-and-null in some samples, a string in others
label: string | null;
}JSON to TS: online tool vs quicktype vs manual
There are three common ways to get from JSON to TypeScript. They differ in speed, control and how much you can automate.
The online generator
Best for a quick one-off conversion during development. Paste a response and copy the interfaces. A browser-based tool like JSON to TypeScript keeps the data on your machine, which matters when the payload is sensitive. The downside is that it is not part of your build, so you re-run it by hand when the API changes.
quicktype (CLI or library)
quicktype is the strongest option for automation. It reads JSON or JSON Schema and emits types for TypeScript and many other languages. You can script it into a codegen step so types regenerate from sample fixtures on every build.
npx quicktype user.json -o user.ts --lang typescript --just-typesManual typing
Writing interfaces by hand gives full control over naming, optional fields and unions. No generator names your types or splits shared shapes as well as you can. It is the right choice for small, stable payloads, or as a cleanup pass over generated output. For a large nested response it is too slow, and too easy to miss a field.
| Approach | Best for | Speed | Automatable | Data stays local |
|---|---|---|---|---|
| Online generator | Quick one-off conversions during dev | Instant | No | Yes (client-side tools) |
| quicktype | Repeatable codegen in a build pipeline | Fast | Yes | Yes (runs locally) |
| Manual typing | Small, stable payloads, final cleanup | Slow | N/A | Yes |
Most teams combine them. They generate a first pass with a tool, then fix names, tighten unions and mark the optional fields the samples did not reveal.
Types are erased: why you still need runtime validation
This catches many newcomers. TypeScript types exist only at compile time. They are removed completely when your code compiles to JavaScript. At runtime there is no User interface and no check. A generated type is a promise you make to the compiler, not a guarantee about the data.
So this compiles cleanly and is wrong as soon as the API returns something unexpected:
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
// TypeScript trusts you here; the runtime does not verify anything
return (await res.json()) as User;
}If the server changes a field, returns null where you expected a string, or the request hits an error page, data.profile.avatarUrl still type-checks and still crashes. The fix is a runtime validator such as Zod. It checks the actual data and derives the static type from the same schema, so you have one source of truth:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
isActive: z.boolean(),
roles: z.array(z.string()),
profile: z.object({
bio: z.string().nullable(),
avatarUrl: z.string().url(),
}),
});
// The static type is derived from the schema, so no separate interface can drift
type User = z.infer<typeof UserSchema>;
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
// Throws a descriptive error if the data doesn't match
return UserSchema.parse(await res.json());
}To formalize the contract as a spec, generate a JSON Schema from your sample and feed it into codegen. quicktype and several validators accept JSON Schema as input, so one definition serves both docs and validation.
A practical workflow
Put together, a reliable process looks like this:
- Capture a real, representative response. Several are better, including edge cases with missing or null fields, so optional fields and unions show up.
- Generate a first set of interfaces with JSON to TypeScript or quicktype.
- Review the output. Rename generic types (
Root,Item) to domain names, check optional fields, and look into any surprising unions. - At every trust boundary, replace the bare cast with a runtime validator (Zod or Valibot) and derive the type from the schema, so the type and the check never drift apart.
- Regenerate whenever the API contract changes. Treat it as codegen, not a one-time task.
Generated types make development fast and safe. The runtime schema makes production safe. You need both, and deriving the type from the schema means you only maintain one definition.