Writing interfaces by hand for a JSON API is slow and error-prone, so most teams generate a first draft from a sample response. That draft is useful, but it is a guess based on one example, and the types only help if the data that arrives at run time actually matches them. This article generates types from a real-looking order response, shows what the generator got right and what it could not know, and then deals with the quieter problem: the places where any slips back into a codebase that looks fully typed. Every output below comes from the JSON to TypeScript tool.
The sample
Here is an order as an API might return it, copied from the browser's network panel:
{
"id": 1042,
"status": "shipped",
"total": 259.9,
"paid_at": null,
"customer": { "id": 7, "name": "Trần Minh An", "email": "[email protected]" },
"items": [
{ "sku": "MUG-01", "qty": 2, "price": 89.95 },
{ "sku": "TEE-L", "qty": 1, "price": 80, "gift_note": "Happy birthday" }
],
"tags": ["priority", "gift"],
"shipping-address": { "city": "Đà Nẵng", "zip": null }
}What the generator produces
export interface Root {
id: number;
status: string;
total: number;
paid_at: null;
customer: Customer;
items: Item[];
tags: string[];
"shipping-address": ShippingAddress;
}
export interface Item {
sku: string;
qty: number;
price: number;
gift_note?: string;
}(Customer and ShippingAddress follow the same pattern.) Three decisions are worth noticing:
- The two order lines were merged. Only the second has
gift_note, so it became optional. One line's price is89.95and the other's is80; both arenumber, and in the Go output the field isfloat64rather thanint64because one sample had a decimal. - The key with a hyphen was quoted.
"shipping-address"is a valid property name, it just cannot be written without quotes, and you read it asorder["shipping-address"]. - `paid_at` is typed `null`. That looks wrong but is the only honest answer: every sample had null there. The generator cannot know that a paid order carries a timestamp.
Tighten what a sample cannot know
A sample shows one response. Before committing the types, edit them with what you know about the API:
- Fields that were null in the sample.
paid_at: nullshould bepaid_at: string | null. The fastest way to get this automatically is to paste two orders inside an array, one unpaid and one paid; the generator merges them intostring | null. - Strings with a fixed set of values.
status: stringaccepts"shiped". Replace it withstatus: "pending" | "paid" | "shipped" | "cancelled", and the compiler will flag the typo and tell you which cases aswitchforgot. - Dates. JSON has no date type, so timestamps arrive as strings. Keep them as
stringin the transport type, and convert toDatein one place rather than pretending the API sendsDateobjects. - Identifiers.
id: numberis fine until an ID passes 2^53 − 1 (9,007,199,254,740,991), which some databases reach with 64-bit keys. Those must be sent as strings, because JavaScript numbers cannot hold them exactly. - Names.
Rootshould beOrder. Set the root name in the tool before generating so nested names read well too.
Why any creeps back
After all that work it is common to find the types do not protect anything, because the value that flows into them was never checked. These are the usual leaks:
- `response.json()` returns `Promise<any>`. In the DOM type definitions it is declared that way. So
const order: Order = await res.json()compiles whatever the server sends; the annotation is an assertion, not a check. - `JSON.parse` returns `any` for the same reason, and so does reading from
localStoragethrough it. - `as` casts.
data as Ordersilences the compiler without looking atdata. - Loose helper types. A shared
Record<string, any>or a function typed(x: any) => voidspreadsanyto everything it touches, and ananyproperty access returns anotherany. - Untyped dependencies. A library without types, or with
anyin its public signatures, passes it straight into your code.
The fix is to treat the network as untrusted. Type the raw value as unknown, which forces a check before use, and validate it once at the boundary. The tool's Zod output does exactly that:
const order = OrderSchema.parse(await res.json()); // throws if the shape is wrong
// order is fully typed from here on, and the type and the check cannot drift apartThen make the compiler and linter help: strict mode (which includes noImplicitAny and, since TypeScript 4.4, useUnknownInCatchVariables), and the typescript-eslint rules no-explicit-any and the no-unsafe-* family, which flag an any being assigned, called or returned.
Common mistakes
- Generating from one record. One record cannot show optional fields or nulls with values. Paste several, ideally edge cases: an empty list, a cancelled order, a customer without an address.
- Treating the generated types as the contract. If the API publishes an OpenAPI or JSON Schema document, generate from that instead; a sample is a fallback.
- Typing `{}` for an empty object. In TypeScript
{}means "any non-null value". The tool writesRecord<string, unknown>instead. - Naming a type after a built-in. A key called
datewould giveinterface Date, which merges with JavaScript's ownDateacross the whole program. The tool renames itDateType. - Assuming camelCase. Many APIs send
snake_case. Renaming properties in the types without converting the data gives you types for keys that never exist.
Try it
Paste a response from your own API into the JSON to TypeScript tool, switch between TypeScript, Zod and Go to see how each language records the same facts, and check malformed input in the JSON formatter first if the parser reports an error.