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.
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.
interface User {
id: number;
name: string;
}
interface User {
email: string;
}
// User now has id, name and emailThey can also extend other interfaces:
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.
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:
{
"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:
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:
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:
{
"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:
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:
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:
interface User {
id: number;
name: string;
avatarUrl: string | null;
}A field that may be missing:
interface User {
id: number;
name: string;
nickname?: string;
}A field that may be missing or null:
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
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:
- Call the endpoint with curl, Postman or your browser’s network tab, and copy the JSON body.
- Paste it into the API to TypeScript tool.
- 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). - Select Generate and copy the output.
- Review it: rename types, fix nullability and add unions where needed.
With the root name set to User, the nested JSON above becomes:
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
addressinsideUserbecomesUserAddress. - For an array of objects, the item type is built from the first object and named with an
Itemsuffix, such asUserTagsItem. Fields that appear only in later items aren't included. - Arrays of mixed primitives become a union array, such as
(string | number)[]. - A
nullvalue is typed asnull, because the sample doesn't say what the field holds when it's set. Change it tostring | nullor 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
stringand parse them intoDateobjects where you need to. - Numeric strings. Some APIs send numbers as strings, such as
"price": "19.99". Type them asstringand convert in your code. - Empty arrays. A generator can't tell the element type from
[], so you getunknown[]. 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.
