Generate TypeScript Types from JSON: A Practical Guide
How to turn raw JSON payloads into accurate TypeScript interfaces — automatically or by hand — and avoid the type-drift bugs that hit every growing API.
Why manually-written types drift and break production
TypeScript's core promise is that type errors surface at compile time, not at runtime in front of users. But that promise breaks down the moment your types stop matching the actual shape of the data they describe. This happens constantly with JSON API types because the types are written by hand, often once, at the time the API is first integrated — and then the API changes without the types changing with it.
A backend developer renames a field from user_id to userId. They update the documentation, maybe even open a PR to update an OpenAPI spec. But the TypeScript interface in your frontend codebase still has user_id: string. The build passes. The tests pass — because they use fixtures written before the rename. The bug lands in production on a code path that only executes in certain conditions, and it takes hours to trace back to the type mismatch.
The fundamental problem is treating types as documentation rather than contracts. A type that is generated from the actual runtime data — or validated against it at runtime — is a contract. A type written once and never checked is documentation that rots.
The two approaches: generation vs derivation
When developers talk about "generating TypeScript types from JSON", they usually mean one of two things. The first is static generation: you paste a JSON sample into a tool, it infers types from the structure and values, and you copy the resulting interfaces into your codebase. The second isruntime derivation: you use a library like Zod, io-ts, or Valibot to define a schema that both validates the data at runtime and exposes a TypeScript type derived from that schema. Both approaches are valid, and the right one depends on your constraints.
Static generation: when to use it and what to watch for
Static generation is fast and zero-dependency. You paste a JSON payload into a generator, get interfaces back, and paste them into your code. For many projects — particularly those calling third-party APIs where you cannot modify the schema — this is the most practical approach.
The main limitation is that a generator can only infer from the sample you provide. If a field is optional and happens to be present in your sample, the generator will make it required. If a field can be null or a different type under certain conditions, the generator will only see the type it sees in the sample. The result is types that are accurate for the sample but potentially wrong for the full range of data the API can return.
To get accurate types from static generation: collect multiple samples covering edge cases — empty arrays, null fields, missing optional fields, different enum values. Run them all through the generator and merge the results manually. The generated types are a starting point, not a final answer.
What a good generator produces for a payment API response
// Input JSON
{
"id": "pay_abc123",
"amount": 4999,
"currency": "EUR",
"status": "succeeded",
"customer": {
"id": "cus_xyz",
"email": "user@example.com",
"name": null
},
"metadata": {},
"created_at": "2026-06-01T12:00:00Z"
}
// Generated TypeScript
export interface PaymentCustomer {
id: string;
email: string;
name: string | null;
}
export interface Payment {
id: string;
amount: number;
currency: string;
status: string;
customer: PaymentCustomer;
metadata: Record<string, unknown>;
created_at: string;
}Notice what the generator gets right: the nested object gets its own interface, nameis correctly typed as string | null because it was null in the sample, andmetadata becomes Record<string, unknown> because it was an empty object. But status is typed as string when it should probably be a union like 'succeeded' | 'pending' | 'failed' | 'refunded'. And created_atis a string when your codebase might prefer it as a Date after parsing. These refinements are always manual.
Runtime derivation with Zod
Zod solves the drift problem by making the type and the validator the same thing. You write a schema once, validate the data at the API boundary, and if validation passes, TypeScript knows the exact shape of the parsed result — inferred from the schema, not written separately.
The same payment type written as a Zod schema
import { z } from 'zod';
const PaymentStatus = z.enum(['succeeded', 'pending', 'failed', 'refunded']);
const PaymentCustomerSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string().nullable()
});
const PaymentSchema = z.object({
id: z.string().startsWith('pay_'),
amount: z.number().int().positive(),
currency: z.string().length(3),
status: PaymentStatus,
customer: PaymentCustomerSchema,
metadata: z.record(z.unknown()),
created_at: z.string().datetime().transform(s => new Date(s))
});
// TypeScript type inferred automatically
type Payment = z.infer<typeof PaymentSchema>;
// Usage at the API boundary
async function fetchPayment(id: string): Promise<Payment> {
const res = await fetch(`/api/payments/${id}`);
const raw = await res.json();
return PaymentSchema.parse(raw); // throws ZodError if shape is wrong
}With Zod, status is now a proper enum, created_at is transformed into aDate, amount is constrained to positive integers, and the email is validated. If the API ever returns data that does not match, PaymentSchema.parse() throws aZodError with a detailed error message showing exactly which field failed and why. This turns silent type-drift bugs into loud, immediately-diagnosable errors.
When each approach wins
Use static generation when you are integrating a third-party API quickly, you need types immediately without adding dependencies, or the API is stable and well-documented with an existing OpenAPI spec you can use to generate types automatically via tools likeopenapi-typescript.
Use runtime derivation (Zod) when you are building or maintaining the consumer of an API that changes frequently, you need to validate external data at runtime (not just at compile time), or you want the guarantee that incorrect data causes a diagnosable error rather than a silent wrong-type bug that propagates through your application.
The OpenAPI path: generating from specs
If the API you are consuming has an OpenAPI specification — and most well-maintained APIs do — you can skip hand-writing types entirely. The openapi-typescript library takes an OpenAPI spec file (either locally or via URL) and generates TypeScript interfaces for every endpoint, request body, and response schema in the spec.
# Generate types from a local OpenAPI spec npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts # Or from a remote spec URL npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.d.ts
The output is a set of TypeScript types that exactly match the spec. If you run this command as part of your build pipeline or CI, the types stay in sync with the spec automatically. When the API team pushes a breaking change to the spec, your build fails — and you find out before your users do.
Common mistakes to avoid
Using any as a fallback. When a generated type is inconvenient, developers reach for any. This silences the error but destroys type safety for the entire downstream data flow. Use unknown instead and narrow to a specific type before use.
Not handling null separately from optional. A field that is{ value?: string } (optional, may not exist in the object) behaves differently from{ value: string | null } (always present but may be null). Generators can infer this from samples if the samples are representative — but they cannot tell from a single sample whether a missing field is truly optional or just absent in that one response.
Not typing dates as strings explicitly. JSON has no Date type — dates arrive as strings. Generated types will correctly type them as string. If you convert them toDate objects in application code, update the type to reflect that, or use a Zod.transform() to handle the conversion at the boundary.
Writing types for the entire response instead of what you use. If an API response has 40 fields and you use 5, it is tempting to only type those 5. The problem is that TypeScript will then allow the remaining 35 to have any value without warning you. Type the full shape, then pick the fields you need with TypeScript's Pick utility.
A practical workflow
For a new API integration, the workflow that avoids most drift bugs is: paste multiple real responses (including edge cases) into a type generator to get a starting-point interface; refine the generated types manually — tighten string fields to specific literals or enums, mark fields that can be null or undefined, change string date fields if you transform them; wrap the generated type in a Zod schema for runtime validation at the API boundary; and if the API has an OpenAPI spec, automate the whole process by running openapi-typescript in CI so types stay current automatically.
Types that reflect the real shape of data are a net multiplier on the reliability of everything downstream — from API calls to UI rendering to test assertions. The upfront cost of accurate types is always less than the debugging cost of discovering drift in production.