Blog

How to generate TypeScript types from JSON (API response to types)

Why typed API responses matter, how to model nested objects, arrays, unions and null in TypeScript, and how to generate a first draft of the types from a real response.

February 10, 2026

You've added TypeScript to the project, the build is green, and then production logs Cannot read properties of undefined. The culprit is almost always data from an API, because TypeScript has no idea what shape that data has unless you tell it.

This guide covers how to describe JSON responses with types, the details that are easy to get wrong (nested objects, arrays, null), and how to generate a first draft from a real response instead of typing it all out.

Why typed API responses matter

response.json() returns any, which is TypeScript for "I give up". Once data is any, nothing is checked: misspelled fields quietly return undefined, and null values slip through without a warning.

TypeScript
const response = await fetch("/api/users");
const data = await response.json(); // any

// No error, even if the field is really called emailAddress
data.forEach((user: any) => {
  console.log(user.email);
});

If the API returns emailAddress, this prints undefined for every user and you find out in production. With a User type, the compiler flags user.email straight away.

Interface vs type

TypeScript has two ways to describe an object shape: interface and type. Both work for API responses.

Interfaces

Interfaces support declaration merging: two declarations with the same name are combined into one.

TypeScript
interface User {
  id: number;
  name: string;
}

interface User {
  email: string;
}

// User now has id, name and email

They can also extend other interfaces:

TypeScript
interface BaseEntity {
  id: number;
  createdAt: string;
  updatedAt: string;
}

interface User extends BaseEntity {
  name: string;
  email: string;
}

Type aliases

Type aliases can describe anything, not only objects: unions, intersections, primitives, tuples and mapped types.

TypeScript
type ApiResult<T> =
  | { data: T; error: null }
  | { data: null; error: string };

type UserId = number;
type UserRole = "admin" | "editor" | "viewer";

A common convention is interface for object shapes and type for unions and utility types. Pick one style per project and stay consistent.

Nested objects

Real responses are rarely flat:

JSON
{
  "id": 1,
  "name": "Jane Doe",
  "address": {
    "street": "123 Main St",
    "city": "Springfield",
    "state": "IL",
    "zip": "62701"
  },
  "company": {
    "name": "Acme Corp",
    "industry": "Technology"
  }
}

You can inline the nested shapes:

TypeScript
interface User {
  id: number;
  name: string;
  address: {
    street: string;
    city: string;
    state: string;
    zip: string;
  };
  company: {
    name: string;
    industry: string;
  };
}

That works, but the nested types can't be reused. If another endpoint returns an address, you would've to repeat it. Giving each nested object its own named type is easier to maintain:

TypeScript
interface Address {
  street: string;
  city: string;
  state: string;
  zip: string;
}

interface Company {
  name: string;
  industry: string;
}

interface User {
  id: number;
  name: string;
  address: Address;
  company: Company;
}

Arrays and union types

A JSON array can hold objects of different shapes:

JSON
{
  "notifications": [
    { "type": "email", "subject": "Welcome", "body": "..." },
    { "type": "sms", "phone": "+1234567890", "message": "..." },
    { "type": "push", "title": "New feature", "payload": { "screen": "home" } }
  ]
}

A discriminated union models this accurately:

TypeScript
interface EmailNotification {
  type: "email";
  subject: string;
  body: string;
}

interface SmsNotification {
  type: "sms";
  phone: string;
  message: string;
}

interface PushNotification {
  type: "push";
  title: string;
  payload: { screen: string };
}

type Notification = EmailNotification | SmsNotification | PushNotification;

interface NotificationResponse {
  notifications: Notification[];
}

The shared type field is the discriminant. In a switch or if on it, TypeScript narrows to the matching member:

TypeScript
function handleNotification(n: Notification) {
  switch (n.type) {
    case "email":
      console.log(n.subject); // n is EmailNotification
      break;
    case "sms":
      console.log(n.phone); // n is SmsNotification
      break;
    case "push":
      console.log(n.payload.screen); // n is PushNotification
      break;
  }
}

A generator can't infer a union like this from one sample, because it doesn't know which field is the discriminant or which literal values are possible. Write unions by hand.

Null and undefined

JSON has null but no undefined. A field that is missing from the response reads as undefined in JavaScript, so the two cases need different types.

A field that's always present but can be null:

TypeScript
interface User {
  id: number;
  name: string;
  avatarUrl: string | null;
}

A field that may be missing:

TypeScript
interface User {
  id: number;
  name: string;
  nickname?: string;
}

A field that may be missing or null:

TypeScript
interface User {
  id: number;
  name: string;
  middleName?: string | null;
}

Wrong nullability is one of the most common sources of runtime errors with external APIs. When you'ren't sure, allow null and check for it.

Using the types with fetch

TypeScript
interface Paginated<T> {
  data: T;
  meta: {
    total: number;
    page: number;
    perPage: number;
  };
}

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  createdAt: string;
}

async function fetchUsers(page = 1): Promise<Paginated<User[]>> {
  const response = await fetch(`/api/users?page=${page}`);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return (await response.json()) as Paginated<User[]>;
}

The as cast does no runtime checking. If the API returns something else, TypeScript won't notice. For data you don't control, validate the response with a library such as Zod, Valibot or ArkType.

Generating types from a real response

Typing out interfaces for a 300-line response is slow, boring and a good way to introduce typos. It's quicker to generate a first draft from a real response and then tidy it up:

  1. Call the endpoint with curl, Postman or your browser’s network tab, and copy the JSON body.
  2. Paste it into the API to TypeScript tool.
  3. Set the options: the root interface name (default ApiResponse), Use type instead of interface, Optional properties (adds ? to every field) and Add export (on by default).
  4. Select Generate and copy the output.
  5. Review it: rename types, fix nullability and add unions where needed.

With the root name set to User, the nested JSON above becomes:

TypeScript
export interface UserAddress {
  street: string;
  city: string;
  state: string;
  zip: string;
}

export interface UserCompany {
  name: string;
  industry: string;
}

export interface User {
  id: number;
  name: string;
  address: UserAddress;
  company: UserCompany;
}

A few things to know about how the tool infers types:

  • Nested objects get a name made from the parent name and the key, so address inside User becomes UserAddress.
  • For an array of objects, the item type is built from the first object and named with an Item suffix, such as UserTagsItem. Fields that appear only in later items aren't included.
  • Arrays of mixed primitives become a union array, such as (string | number)[].
  • A null value is typed as null, because the sample doesn't say what the field holds when it's set. Change it to string | null or similar.
  • If the root is an array, the tool types its first element and adds a comment such as // Root type: ApiResponse[].

The tool runs in your browser, so the response isn't uploaded.

Common pitfalls

  • Dates. JSON has no date type, so dates arrive as strings, usually ISO 8601. Type them as string and parse them into Date objects where you need to.
  • Numeric strings. Some APIs send numbers as strings, such as "price": "19.99". Type them as string and convert in your code.
  • Empty arrays. A generator can't tell the element type from [], so you get unknown[]. Use a sample with data in it.
  • Inconsistent responses. Success and error responses, or responses for different query parameters, can have different shapes. Generate from several representative samples and merge the results.

Need data from a webpage as JSON?

PulpMiner turns any public page into structured JSON and can save it as a live API. See it work on a demo page, no sign-up.

Try it on a demo page