TypeScript types from API JSON, and why any creeps back

Generate TypeScript interfaces from a real API response, tighten what a sample cannot know, and stop any from leaking back in at the network boundary.

On this page

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:

  1. The two order lines were merged. Only the second has gift_note, so it became optional. One line's price is 89.95 and the other's is 80; both are number, and in the Go output the field is float64 rather than int64 because one sample had a decimal.
  2. 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 as order["shipping-address"].
  3. `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: null should be paid_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 into string | null.
  • Strings with a fixed set of values. status: string accepts "shiped". Replace it with status: "pending" | "paid" | "shipped" | "cancelled", and the compiler will flag the typo and tell you which cases a switch forgot.
  • Dates. JSON has no date type, so timestamps arrive as strings. Keep them as string in the transport type, and convert to Date in one place rather than pretending the API sends Date objects.
  • Identifiers. id: number is 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. Root should be Order. 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 localStorage through it.
  • `as` casts. data as Order silences the compiler without looking at data.
  • Loose helper types. A shared Record<string, any> or a function typed (x: any) => void spreads any to everything it touches, and an any property access returns another any.
  • Untyped dependencies. A library without types, or with any in 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 apart

Then 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 writes Record<string, unknown> instead.
  • Naming a type after a built-in. A key called date would give interface Date, which merges with JavaScript's own Date across the whole program. The tool renames it DateType.
  • 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.

Tools mentioned in this article

Back to the blog