Utilo

JSON to TypeScript Interfaces: A Practical Guide to Typing API Responses

Learn how to turn JSON into accurate TypeScript interfaces, handle optional fields, nulls, unions and arrays, and keep types in sync with real APIs.

· 5 min read

Every TypeScript project that talks to an API eventually faces the same question: how do you describe the shape of data that arrives as untyped JSON? Leaving responses as any throws away the main benefit of TypeScript. Writing interfaces by hand for a 200-line payload is tedious and error-prone. This guide walks through a reliable workflow for generating interfaces from JSON, refining them, and keeping them honest over time.

Why types for JSON matter

When you call await res.json(), TypeScript gives you any. From that moment on, typos like user.adress.city compile without complaint and only fail at runtime, often in production and often for a subset of users whose data happens to differ. A typed response gives you autocomplete, safe refactoring and compile-time errors when the backend renames a field.

Types also act as documentation. A new team member reading interface Order { id: string; items: OrderItem[]; total: number; couponCode?: string } learns more in five seconds than from scrolling through a sample response.

Step 1: Start from a real, representative sample

Generated types are only as good as the sample you feed in. Before converting, collect a response that exercises as many fields as possible:

  • Pick a record with optional fields filled in, such as an order that has a coupon, a shipping address and a gift message.
  • Include arrays with at least one element; an empty array tells a generator nothing about its contents.
  • If a field can be null, try to include one example where it is null and one where it is not.

Copy the response body from your browser's Network tab or from curl, paste it into a JSON formatter and confirm it is valid. Then switch the mode to → TypeScript.

Step 2: Generate the first draft

Given this input:

{
  "id": "ord_9f2",
  "createdAt": "2026-09-30T12:01:44Z",
  "customer": { "id": 42, "email": "ana@example.com" },
  "items": [
    { "sku": "TSHIRT-M", "qty": 2, "price": 19.5 },
    { "sku": "MUG", "qty": 1, "price": 12 }
  ],
  "coupon": null
}

a generator produces something like:

export interface Customer {
  id: number;
  email: string;
}

export interface Item {
  sku: string;
  qty: number;
  price: number;
}

export interface Root {
  id: string;
  createdAt: string;
  customer: Customer;
  items: Item[];
  coupon: null;
}

This is a solid starting point, but it reflects only what one sample contained. The next steps turn it into a type that matches the API contract.

Step 3: Rename and refine

Generators name nested interfaces after keys. Rename them to domain terms: Root becomes Order, Item becomes OrderItem. Then review each field with these questions.

Can it be missing? If the API omits a key in some responses, mark it optional with ?. Optional (coupon?: Coupon) and nullable (coupon: Coupon | null) are different: the first means the key may be absent, the second means it is always present but may hold null. Many APIs do both, which you express as coupon?: Coupon | null.

Was null the only value seen? A field typed literally as null almost always means the sample was not representative. Replace it with the real type plus | null.

Is a string really a string? Dates arrive as ISO 8601 strings. Keep them as string in the transport type; converting to Date belongs in a mapping layer. You can document intent with a type alias: type ISODateString = string.

Is a number an enum? If status is always one of a few values, use a union of literals: status: "pending" | "paid" | "shipped". This gives exhaustive switch checking.

Are IDs safe as numbers? Database IDs above 2^53 lose precision in JavaScript. If your backend uses 64-bit integers, ask it to send IDs as strings and type them as string.

The refined result:

export type ISODateString = string;

export interface Order {
  id: string;
  createdAt: ISODateString;
  status: "pending" | "paid" | "shipped" | "cancelled";
  customer: Customer;
  items: OrderItem[];
  coupon?: Coupon | null;
}

Step 4: Handle arrays with mixed content

Some APIs return heterogeneous arrays, such as an activity feed with different event types. A naive generator produces a single interface with every field optional, which loses information. A discriminated union is far better:

type FeedEvent =
  | { type: "comment"; id: string; text: string }
  | { type: "like"; id: string; userId: string }
  | { type: "follow"; id: string; followerId: string };

Now TypeScript narrows the type automatically inside if (event.type === "comment"). When converting a mixed array, the tool unions the inferred item types; use that output as a hint to design the discriminated union by hand.

Step 5: Validate at runtime

TypeScript types disappear at compile time. If the backend changes, your code still receives the new shape and your types silently lie. For data that crosses a trust boundary, add runtime validation with a schema library such as Zod, Valibot or ArkType and derive the static type from the schema:

import { z } from "zod";

const OrderSchema = z.object({
  id: z.string(),
  createdAt: z.string().datetime(),
  items: z.array(z.object({ sku: z.string(), qty: z.number().int(), price: z.number() })),
  coupon: z.object({ code: z.string() }).nullable().optional(),
});

export type Order = z.infer<typeof OrderSchema>;
const order = OrderSchema.parse(await res.json());

You can still use the generator to bootstrap: generate the interface, then translate it field by field into the schema.

Step 6: Prefer the source of truth when one exists

If your API publishes an OpenAPI (Swagger) or GraphQL schema, generate types from that instead of from samples. Tools like openapi-typescript and GraphQL Code Generator produce exact types including optionality and enums, and they can run in CI so types update whenever the schema changes. Sample-based generation shines for third-party APIs without schemas, quick prototypes, webhooks and legacy services.

interface or type?

For object shapes, interface and type are nearly interchangeable. Interfaces can be extended and merged, and error messages refer to them by name, which helps with large objects. Type aliases are required for unions, tuples and mapped types. A common convention is interfaces for object shapes and type aliases for everything else.

Common pitfalls

  • Typing everything as optional. It feels safe but forces null checks everywhere and hides real contract violations. Make fields optional only when the API actually omits them.
  • Trusting a single sample. Check documentation or several responses before finalising optionality.
  • Mixing transport and domain types. Keep a raw OrderDTO that mirrors JSON and map it into a domain Order with real Date objects and computed fields.
  • Forgetting about pagination wrappers. Many APIs wrap lists in { data: [...], nextCursor: "..." }. Model this once as a generic: interface Page<T> { data: T[]; nextCursor: string | null }.

A quick checklist

  1. Capture a rich, real sample and validate it.
  2. Generate interfaces with the JSON to TypeScript converter.
  3. Rename types to domain language.
  4. Fix optional versus nullable fields, literal unions and ID types.
  5. Replace mixed arrays with discriminated unions.
  6. Add runtime validation for external data.
  7. Switch to schema-based generation when an OpenAPI or GraphQL schema exists.

Following these steps takes a few minutes per endpoint and pays back many times over in autocomplete, safer refactors and bugs caught before they reach users.

Related guides