Pasting an API response into a generator and getting interfaces back saves real typing. The catch is what those types describe: the one response you pasted, not the API. Every field that happened to be null, every list that happened to be empty and every status that happened to be "active" gets baked into a type the next response may break.
The JSON to TypeScript converter runs in your browser and generates TypeScript interfaces or types, a Zod schema, or a JSON Schema from the JSON you paste. Used carefully, it gets you most of the way to a correct contract. Here is how to close the gap.
What one sample tells you
Take a single user record:
{
"id": 1842,
"email": "ana@example.com",
"status": "active",
"nickname": null,
"createdAt": "2026-03-14T09:30:00Z",
"tags": [],
"address": { "city": "Pune", "zip": "411001" }
}
With the root type named User, the TypeScript output is:
export interface User {
id: number;
email: string;
status: string;
nickname: null;
createdAt: string; // ISO date string — consider using Date
tags: unknown[]; // empty array — could not infer type
address: Address;
}
export interface Address {
city: string;
zip: string;
}
Three of these lines are guesses. nickname: null is accurate for this record and useless as a type; the real field is almost certainly string | null. tags: unknown[] comes from an empty array, which says nothing about its elements, and the converter says so in a comment. status: string is correct but loose if the API only ever returns a handful of values.
The date comment needs care. JSON has no date type, so createdAt arrives as a string and JSON.parse leaves it a string. Typing it as Date without converting it is a lie the compiler will believe. Keep it string, or convert at the boundary and give the converted object its own type.
Give it several samples at once
For TypeScript output, the converter merges every element of an array before inferring a type. Collect records in different states, put them in one array, and paste that:
{
"users": [
{ "id": 1842, "email": "ana@example.com", "status": "active", "nickname": null,
"createdAt": "2026-03-14T09:30:00Z", "tags": [] },
{ "id": "u_1843", "email": "raj@example.com", "status": "suspended", "nickname": "raj",
"createdAt": "2026-03-15T11:02:00Z", "tags": ["beta"], "deletedAt": "2026-04-01T00:00:00Z" },
{ "id": 1844, "email": "li@example.com", "status": "active", "nickname": null,
"createdAt": "2026-03-16T08:00:00Z", "tags": ["beta", "staff"] }
]
}
With the root named UserList:
export interface UserList {
users: User[];
}
export interface User {
id: number | string;
email: string;
status: string; // suggested enum: "active" | "suspended"
nickname: null | string;
createdAt: string; // ISO date string — consider using Date
tags: string[];
deletedAt?: string; // ISO date string — consider using Date
}
Now the types carry real information. deletedAt appeared in only one record, so it is optional. nickname is a union, and the tags have a type. The repeated status values produced an enum suggestion, which stays a comment because two observed values don't prove there aren't five. And id: number | string exposes something worth asking the API team about. Good sources of variation are records in unusual states (deleted, suspended, on a trial), empty and full lists, and the error response.
Optional, null and undefined are different things
JSON has null but no undefined. On the wire a key is either missing or present, and if present its value may be null. TypeScript lets you say exactly which:
deletedAt?: string; // may be missing, never null
deletedAt: string | null; // always present, may be null
deletedAt?: string | null; // either
Pick the one the API documents, not the one your sample happened to show. JSON.stringify drops properties whose value is undefined, so a JavaScript backend that sets a field to undefined sends no key at all. The converter's All optional switch marks every property optional. That suits PATCH bodies, but it also hides fields that are always present, so don't use it as a shortcut for uncertainty.
Numbers, IDs and integers
TypeScript has one number type, and at runtime it is an IEEE 754 double, exact only up to 9007199254740991 (2^53 − 1). JSON.parse rounds anything larger without warning: 9007199254740993 arrives as 9007199254740992. That is why many APIs send 64-bit IDs as strings. If yours sends them as numbers, typing them as number won't help, because the value is already wrong by the time your code sees it. Prefer string IDs end to end.
The Zod and JSON Schema outputs go a step further than TypeScript and distinguish integers: a whole number in the sample becomes z.number().int() or "type": "integer". Check that against reality. A price of 10 in your sample doesn't mean prices are never 10.5.
Types don't check anything at runtime
TypeScript types are erased at compile time. const user = (await res.json()) as User is a promise to the compiler, and nothing checks it. When the backend changes, the failure surfaces far from the cause, as a Cannot read properties of undefined error three components away.
To check data at the boundary, use a runtime validator: Zod, Valibot or io-ts in TypeScript, or a JSON Schema with a validator such as Ajv. The converter generates both a Zod schema and a JSON Schema (draft 2020-12) as starting points. Know their limits: unlike the TypeScript output, they infer each array from its first element and mark every key as required unless All optional is on. For the three-user sample above, the generated Zod schema expects id to be an integer and nickname to be null, so it would reject the second user. Generate the TypeScript first, then tighten the schema to match it:
import { z } from "zod";
const User = z.object({
id: z.union([z.number().int(), z.string()]),
email: z.string(),
status: z.enum(["active", "suspended"]),
nickname: z.string().nullable(),
createdAt: z.iso.datetime(), // Zod 3: z.string().datetime()
tags: z.array(z.string()),
deletedAt: z.iso.datetime().optional(),
});
type User = z.infer<typeof User>;
const result = User.safeParse(await res.json());
if (!result.success) {
console.error(result.error.issues);
}
z.enum rejects any status you haven't listed. That is the point, but only do it when the API documents the full set; otherwise keep z.string() and handle unknown values in code.
A workflow that holds up
- Collect several real responses, including edge cases and errors, and check that each one parses in the JSON validator.
- Put them in one array and generate TypeScript. Read every comment the converter adds.
- Fix the guesses by hand: unions, nullability, enums and string IDs.
- Validate at the boundary with a schema and log failures, so a contract change shows up as one clear error.
- When the API changes, run the old and new responses through the JSON diff before regenerating; comparing two JSON responses covers how to read the result.
Next steps
Paste a few real records into the JSON to TypeScript converter as an array and compare the result with the types you have now. If those records came from production, redact them before sharing the sample with anyone else.