Every fetch call returns any or unknown until you tell TypeScript what shape to expect. Writing those interfaces by hand for a deeply nested API response is tedious and error-prone, which is why generating TypeScript types directly from a JSON sample has become a standard step in front-end and full-stack workflows.
This guide walks through how the conversion actually works: how nested objects become nested interfaces, how arrays map to T[], how optional properties are detected across array items, and how mixed value types collapse into unions. It then compares an online generator, the quicktype CLI, and manual typing, and closes with the one thing generated types can't do: validate data at runtime.
Why Typing API Responses Matters
An untyped API response is a silent liability. When await res.json() returns any, every property access downstream compiles even when the field is misspelled, missing, or the wrong type. The mistake surfaces at runtime as Cannot read properties of undefined — usually in production, usually far from the fetch that caused it.
Typing the response flips that around. Once the shape is declared, the compiler catches user.emial at build time, autocomplete surfaces every field, and refactors that rename a property ripple through the codebase safely. For a nested payload, the difference between guessing at data.results[0].profile.avatarUrl and having it autocompleted is the difference between a quick task and a debugging session.
- 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.
The catch is that hand-writing these interfaces for a real API — pagination wrappers, nested relations, arrays of objects — is slow and easy to get wrong. Generating them from an actual 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 runtime 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 extracts it into its own named interface rather than inlining an anonymous type. That keeps the output readable and lets you 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"] }
]
}A structural 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 the type you would hand-tune it to 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 inspects the array's elements to infer T — which is where optional-property detection comes in, covered next.
Optional Properties, Nulls, and Unions
Real JSON is rarely uniform. The same field can be present in one object and absent in another, or hold a string here and a number there. How a generator handles these variations is what separates a usable type from one you have to rewrite by hand.
Optional properties detected across array items
A single object can't tell you whether a field is optional — every key it has is, by definition, present. Optionality only becomes visible across multiple samples. When a generator sees an array of objects, it takes the union of all keys 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[];
}This is why a single-object sample often produces types with no optional fields at all — there's nothing to compare against. Feeding in an array with realistic variation gives the generator the evidence it needs.
Null values and unions for mixed types
A JSON null can't be typed precisely on its own — null alone tells you nothing about what the field holds when it isn't null. Generators handle it in one of two ways: emit null (or any) if that's all they see, or, given a sample where the same field is sometimes null and sometimes a value, produce a union like string | null.
The same logic applies when a field holds different primitive types across samples. If id appears as both a number and a string, a good generator collapses that into a union rather than silently 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;
}Generating Types: Online Tool vs quicktype vs Manual
There are three common ways to get from JSON to TypeScript. They trade off speed, control, and how much of the pipeline you're willing to automate.
→tsJSON to TypeScriptPaste a JSON sample and get clean TypeScript interfaces instantly — nested objects extracted into named types, arrays resolved to T[], and optionals inferred. It runs entirely in your browser, so response payloads never leave your machine.→The online generator
Best for a fast, one-off conversion during development: paste a response, copy the interfaces, done. A browser-based tool like JSON to TypeScript keeps the data client-side, which matters when the payload contains anything sensitive. The trade-off is that it isn't wired into your build, so you re-run it manually whenever the API changes.
quicktype (CLI / library)
quicktype is the most capable option for automation. It reads JSON (or JSON Schema) and emits types for TypeScript and many other languages, and it can be scripted 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 total control over naming, optionality, and unions — no generator will name your types or split shared shapes as well as you can. It's the right call for small, stable payloads, or as a cleanup pass over generated output. For a large nested response it's simply 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 |
In practice most teams combine them: generate a first pass with a tool, then hand-tune names, tighten unions, and mark the optionals the samples didn't reveal.
Types Are Erased: Why You Still Need Runtime Validation
Here's the gap that trips up newcomers. TypeScript types exist only at compile time — they're stripped out entirely when your code is transpiled to JavaScript. At runtime there is no User interface, no check, nothing. A generated type is a promise you make to the compiler, not a guarantee about the data.
So this compiles cleanly and lies to you the moment 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, which checks the actual data and derives the static type from the same schema — 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 — no separate interface to 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());
}If you'd rather formalize the contract as a spec, generate a JSON Schema from your sample and feed that into codegen — quicktype and several validators accept JSON Schema as input, giving you one canonical definition for both docs and validation.
A Practical Workflow
Putting it together, a reliable end-to-end process looks like this:
- Capture a real, representative response — ideally several, including edge cases with missing or null fields so optionals and unions are detectable.
- Generate a first-pass set of interfaces with JSON to TypeScript or quicktype.
- Review the output: rename generic types (
Root,Item) to domain names, verify optionals, and inspect any surprising unions. - At every trust boundary, replace the bare cast with a runtime validator (Zod/Valibot) and derive the type from the schema so type and check never drift apart.
- Regenerate whenever the API contract changes — treat it as codegen, not a one-time task.
The generated types make development fast and safe; the runtime schema makes production safe. You need both, and keeping the type derived from the schema means you only maintain one definition.