From JSON to TypeScript Types: A Complete Guide
Turn JSON API responses into accurate TypeScript types. Learn interfaces vs type aliases, nested data, optional and nullable fields, unions, and validation.
Most TypeScript bugs at the network boundary come from the same mistake: trusting that a JSON response matches what you expect. Typing those responses turns silent runtime surprises into compile-time errors. This guide walks through generating accurate TypeScript types from JSON, the tradeoffs you will hit, and how to keep the types honest at runtime.
Why typing API responses matters
When you fetch data, the value you get back is untyped. A common pattern looks like this:
const res = await fetch("/api/user");
const data = await res.json(); // data is `any`
console.log(data.fistName); // typo, no error, undefined at runtime
res.json() returns any (or Promise<any>). That any spreads through your
code and disables every check TypeScript would otherwise give you. A typo like
fistName compiles fine and fails silently in production. Giving the response a
concrete type restores autocomplete, catches typos, and documents the shape of
your data in one place.
Interfaces vs type aliases
Given this JSON:
{
"id": 42,
"firstName": "Ada",
"isActive": true
}
You can describe it with an interface or a type alias. Both work here:
interface User {
id: number;
firstName: string;
isActive: boolean;
}
type UserAlias = {
id: number;
firstName: string;
isActive: boolean;
};
Use interfaces for object shapes you expect to extend or that other code implements; they merge declarations and produce cleaner error messages. Use type aliases when you need unions, intersections, mapped types, or tuples, which interfaces cannot express. For plain API payloads either is fine — pick one and stay consistent across the codebase.
Handling nested objects and arrays
Real responses nest. Consider an order with a customer and line items:
{
"orderId": "ord_001",
"customer": {
"id": 7,
"email": "ada@example.com"
},
"items": [
{ "sku": "A1", "qty": 2, "price": 9.99 },
{ "sku": "B2", "qty": 1, "price": 19.5 }
]
}
Model each nested object as its own named type rather than inlining everything. Named types are reusable and produce readable errors:
interface Customer {
id: number;
email: string;
}
interface LineItem {
sku: string;
qty: number;
price: number;
}
interface Order {
orderId: string;
customer: Customer;
items: LineItem[];
}
For arrays you have two equivalent syntaxes: LineItem[] and Array<LineItem>.
The bracket form reads better in most cases; the generic form is handy when the
element type is itself complex. For a list endpoint that returns a bare array,
the top-level type is simply Order[].
Optional and nullable fields
JSON has two distinct ways a field can be "missing," and they map to different
TypeScript constructs. A field that may be absent from the object is optional
(?). A field that is present but may hold null is nullable (| null).
{
"id": 1,
"nickname": null
}
interface Profile {
id: number;
nickname: string | null; // present, but can be null
bio?: string; // may be absent entirely
avatarUrl?: string | null; // may be absent OR null
}
This distinction matters. If you type a sometimes-missing field as string
instead of string | undefined, TypeScript will let you call .toUpperCase() on
a value that is actually undefined at runtime. Be precise: ? for absence,
| null for explicit null, and combine both when the API does both.
Unions for variant shapes
Some endpoints return different shapes depending on a status or type field. A discriminated union models this safely:
{ "type": "success", "data": { "token": "abc" } }
{ "type": "error", "message": "Invalid credentials" }
type AuthResult =
| { type: "success"; data: { token: string } }
| { type: "error"; message: string };
function handle(r: AuthResult) {
if (r.type === "success") {
return r.data.token; // narrowed to the success variant
}
return r.message; // narrowed to the error variant
}
The shared literal field (type) is the discriminant. Checking it lets
TypeScript narrow the union to one branch, so you can only access fields that
actually exist on that branch.
unknown vs any
When you genuinely do not know a value's shape — a third-party webhook, an
arbitrary metadata blob — reach for unknown, never any. Both accept any
value, but unknown forces you to check before using it, while any disables
all checking and quietly infects everything it touches.
function parse(input: unknown) {
// input.foo; // error: object is of type 'unknown'
if (typeof input === "object" && input !== null && "foo" in input) {
// safe to inspect now
}
}
Treat any as a code smell and unknown as the honest default for untyped data.
Runtime validation
Here is the catch every TypeScript developer eventually learns: types are erased at compile time. They describe what you expect, but they do not check the data you actually receive. If the API changes a field from a number to a string, your typed code compiles and still breaks at runtime.
To close that gap, validate the payload against a schema before trusting it. You can generate a schema from a sample with the JSON Schema Generator and check responses with the JSON Schema Validator. In code, libraries like Zod let your runtime check and your static type stay in sync:
import { z } from "zod";
const User = z.object({
id: z.number(),
firstName: z.string(),
isActive: z.boolean(),
});
type User = z.infer<typeof User>; // type derived from the schema
const data = User.parse(await res.json()); // throws if the shape is wrong
Now the static type and the runtime guard come from a single source of truth.
Generating types automatically
Writing these types by hand is tedious and error-prone for large payloads. Paste a representative response into the JSON to TypeScript tool and it infers interfaces, nested types, arrays, and optional fields for you in seconds. A couple of tips for clean output:
- Feed it a complete example. Fields missing from your sample cannot be
inferred, and a
nullvalue alone tells the generator nothing about the real type, so prefer a populated record. - Merge several samples mentally for fields that vary, then mark them optional or union them after generating.
- Rename the generated root type to something meaningful and move shared nested types into their own declarations if you reuse them.
The generator gets you 90 percent of the way; you apply judgment for optionality, nullability, and unions that a single example cannot reveal.
Conclusion
Typing your JSON gives you autocomplete, refactoring safety, and living
documentation — but only if the types are accurate. Model nested data as named
types, distinguish optional from nullable, use discriminated unions for variants,
prefer unknown over any, and back it all with runtime validation so the types
do not lie. Start by dropping a real response into the
JSON to TypeScript tool, then refine the output and
pair it with a schema check. Explore the rest of the tools on
JSONPost to round out your JSON workflow.
Keep reading
Validating JSON with JSON Schema
Learn how JSON Schema works, how to generate a schema from sample data, and how to validate documents with clear, path-based error messages.
JSON vs JSON5 vs JSONC — What's the Difference?
Comments, trailing commas, and unquoted keys — understand JSON5 and JSONC, where each is used, and how to convert them to strict JSON.
How to Format JSON (and Why It Matters)
A practical guide to beautifying, indenting, and minifying JSON — and when to use each, with tips for debugging messy API responses.