JSON to TypeScript in Practice
Generate TypeScript types from JSON samples — handling optional fields, nullable, unions, nested types — plus runtime validation strategies.
When you call an API that returns JSON, TypeScript needs a type for the result. Writing that type by hand for a 40-field response with five nested objects is error-prone and tedious. Generating it from a sample takes seconds. This guide covers the practical workflow: from sample to type, where generators get things wrong, and how to add runtime validation so your types aren't lies.
Why generate types from JSON
Two reasons to skip the hand-written interface:
- Coverage — a generator sees every field in the sample, including the ones you'd skim past.
- Drift — when the API adds a field, regenerating is a 5-second operation. Hand-written types rot.
The trade-off — generators infer types from one sample. If the sample doesn't include a nullable field as null, the generator marks it non-nullable. You always need to review the output.
From a sample to an interface
Given this JSON:
{
"id": "user_123",
"email": "ada@example.com",
"age": 36,
"active": true,
"tags": ["admin", "founder"],
"address": {
"street": "10 Downing",
"city": "London"
}
}
A reasonable generated type:
interface User {
id: string;
email: string;
age: number;
active: boolean;
tags: string[];
address: Address;
}
interface Address {
street: string;
city: string;
}
Tools that do this:
- The JSON to TypeScript generator — paste a sample, copy out the types.
- quicktype — the same engine, CLI and library versions available.
- VS Code: "Paste JSON as Code" extension.
Handling optional and nullable fields
A generator sees one sample. Two things you almost always have to fix by hand:
Optional fields. If a field is present in some responses and absent
in others, you want field?: T, not field: T. Feed the generator
multiple samples — most tools merge them and infer optionals from
fields that aren't in every sample.
Nullable fields. A field that can be null should be typed
T | null:
interface User {
// present sometimes, absent sometimes
middleName?: string;
// present always, sometimes null
deletedAt: string | null;
// can be missing OR null
avatarUrl?: string | null;
}
The three flavours are subtly different and TypeScript is strict about the difference. When in doubt, look at the API documentation, not the sample.
Unions from heterogeneous arrays
When an array contains objects of different shapes, the generator emits a union:
{
"events": [
{ "kind": "click", "target": "button-1" },
{ "kind": "page-view", "path": "/home" }
]
}
type Event =
| { kind: "click"; target: string }
| { kind: "page-view"; path: string };
The literal type "click" (rather than string) makes this a
discriminated union — TypeScript can narrow based on kind:
function handle(event: Event) {
if (event.kind === "click") {
event.target; // typed as string
} else {
event.path; // typed as string
}
}
If your generator emits kind: string instead of kind: "click",
tighten by hand. The discriminator is almost always worth narrowing.
Naming and nested types
Generators name nested types from the parent field — User.address
becomes Address, Order.shippingAddress becomes ShippingAddress.
Two issues:
- Collisions — two unrelated
Addresstypes in different parts of the document. Rename one (BillingAddress,ShippingAddress). - Inline vs named — for tiny one-off types, inline is fine. For anything reused across multiple endpoints, give it a name and export.
The /json/types/typescript tool lets you set a root name and emits all nested types alphabetically.
as const for literal-typed fixtures
If your JSON is a fixture (test data, configuration) and you want
TypeScript to know the exact values, not just the types, use
as const:
const config = {
env: "production",
retries: 3,
features: ["billing", "auth"],
} as const;
// type: { readonly env: "production"; readonly retries: 3; readonly features: readonly ["billing", "auth"] }
This is useful for switch statements and exhaustiveness checks.
Runtime validation vs compile-time types
A generated type is a compile-time contract. At runtime, the API might return anything. If you cast and trust:
const user = JSON.parse(text) as User; // ← a lie
…you'll get cryptic errors later when user.email is undefined.
The robust pattern is to define the shape once in a runtime validator and derive the TypeScript type from it. With Zod:
import { z } from "zod";
const User = z.object({
id: z.string(),
email: z.string().email(),
age: z.number().int(),
active: z.boolean(),
tags: z.array(z.string()),
address: z.object({
street: z.string(),
city: z.string(),
}),
});
type User = z.infer<typeof User>;
function parseUser(text: string): User {
return User.parse(JSON.parse(text)); // throws if shape is wrong
}
Similar libraries:
- Yup — older, more Joi-like API.
- io-ts — fp-ts ecosystem; more verbose, more powerful.
- Valibot — smaller bundle than Zod, similar API.
@sinclair/typebox— JSON Schema-first; emits valid JSON Schema.
For a deeper look at parsing safely, see safely parsing JSON in JavaScript.
Keeping types in sync with the API
A few options, from cheapest to most reliable:
- Manual — paste a sample, regenerate, commit. Fine for small or rarely-changing APIs.
- Schema-driven — if the API publishes JSON Schema or OpenAPI,
generate types from that with
openapi-typescriptorjson-schema-to-typescript. The schema is the contract; the type is derived. - End-to-end — tRPC, GraphQL Code Generator, or sharing types between server and client packages. The compiler enforces the contract at both ends.
Match the strategy to the cost of drift. For a public REST API you don't own, manual regeneration is usually fine. For an internal API your team owns, schema-driven or shared types pay back the setup.
Generate yours
Paste a JSON sample into the JSON to TypeScript tool to get types in TypeScript, Go, Python, Rust, Java, Swift, Kotlin, or C#. Combine with a runtime validator for the production-safe pattern.
Next steps
- Safely parsing JSON in JavaScript — runtime validation patterns.
- JSON best practices for REST APIs — design the payloads you're typing.