Working with external APIs in TypeScript without proper type definitions is like driving without headlights. Your code compiles, but you are constantly guessing about the shape of the data you receive. A single typo in a property name or a wrong assumption about a field being nullable can cause runtime errors that TypeScript was designed to prevent. In this guide, we will explain why type-safe API consumption matters, how to generate TypeScript types from JSON responses, and the nuances you need to understand to do it correctly.
Why Type-Safe APIs Matter
TypeScript's value proposition rests on catching errors at compile time rather than at runtime. When you fetch data from an API and assign it to a variable typed as any, you lose every benefit TypeScript provides. Property access is unchecked. Misspelled field names silently return undefined. Null values slip through without warning.
Consider a simple example. You fetch a list of users from an API and want to display their email addresses. Without types, you might write:
const response = await fetch("/api/users");
const data = await response.json(); // type is 'any'
// No error, but 'email' might not exist
data.forEach((user: any) => {
console.log(user.email);
});If the API actually returns emailAddress instead of email, you will not know until the code runs in production and outputs undefined for every user. With proper types, TypeScript would immediately flag the incorrect property name.
Interface vs Type Keyword
TypeScript offers two primary ways to define object shapes: interface and type. Both work for defining API response types, but they have meaningful differences.
Interfaces
Interfaces are the traditional way to describe object shapes in TypeScript. They support declaration merging, which means you can define the same interface in multiple places and TypeScript will combine them:
interface User {
id: number;
name: string;
}
interface User {
email: string;
}
// Merged result:
// interface User {
// id: number;
// name: string;
// email: string;
// }Interfaces also support extending other interfaces with the extends keyword, which creates clear inheritance hierarchies:
interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
interface User extends BaseEntity {
name: string;
email: string;
}Type Aliases
Type aliases are more flexible. They can represent not just object shapes but also unions, intersections, primitives, tuples, and mapped types:
type ApiResponse<T> = {
data: T;
error: null;
} | {
data: null;
error: string;
};
type UserId = number;
type UserRole = "admin" | "editor" | "viewer";For API response types specifically, either approach works well. The general community consensus is to use interface for object shapes and type for unions, intersections, and utility types. Most JSON-to-TypeScript generators produce interfaces by default.
Handling Nested Objects
Real-world API responses are rarely flat. They contain nested objects, and how you type those nested structures affects code maintainability. Consider this JSON response:
{
"id": 1,
"name": "Jane Doe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip": "62701"
},
"company": {
"name": "Acme Corp",
"industry": "Technology"
}
}A naive approach inlines everything:
interface User {
id: number;
name: string;
address: {
street: string;
city: string;
state: string;
zip: string;
};
company: {
name: string;
industry: string;
};
}This works, but the nested types are not reusable. If another endpoint returns an address or company object, you would need to duplicate the definition. A better approach extracts each nested object into its own named type:
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;
}Good type generators automatically detect repeated structures and extract them into named types.
Arrays and Union Types
JSON arrays can contain elements of different types, which requires union types in TypeScript. Consider this response:
{
"notifications": [
{ "type": "email", "subject": "Welcome", "body": "..." },
{ "type": "sms", "phone": "+1234567890", "message": "..." },
{ "type": "push", "title": "New feature", "payload": { "screen": "home" } }
]
}A discriminated union accurately models this pattern:
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[];
}Discriminated unions are one of TypeScript's most powerful features. The shared type field acts as a discriminant, and TypeScript can narrow the type within a switch statement or conditional check:
function handleNotification(n: Notification) {
switch (n.type) {
case "email":
console.log(n.subject); // TypeScript knows 'subject' exists
break;
case "sms":
console.log(n.phone); // TypeScript knows 'phone' exists
break;
case "push":
console.log(n.payload.screen); // TypeScript knows 'payload' exists
break;
}
}Null and Undefined Handling
JSON has a null value but no undefined. However, when a field is absent from a JSON response, accessing it in JavaScript returns undefined. This distinction matters when typing API responses.
A field that is always present but sometimes null should be typed with a union:
interface User {
id: number;
name: string;
avatarUrl: string | null; // always present, sometimes null
}A field that may be absent entirely should use the optional modifier:
interface User {
id: number;
name: string;
nickname?: string; // may not be present in the response at all
}Some APIs use both patterns. A field might be absent in some responses and explicitly null in others. The most defensive type combines both:
interface User {
id: number;
name: string;
middleName?: string | null; // might be absent, or present as null
}Getting null handling right prevents the most common class of runtime errors in TypeScript applications consuming external APIs. When in doubt, be more permissive with nullability and use runtime checks.
Practical Workflow with Fetch
Once you have your types defined, integrating them with the Fetch API is straightforward. Here is a practical pattern:
interface ApiResponse<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<ApiResponse<User[]>> {
const response = await fetch(`/api/users?page=${page}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json: ApiResponse<User[]> = await response.json();
return json;
}Note that the type assertion on response.json() does not perform any runtime validation. The data might not match your type at all. For production applications, consider adding runtime validation using a library like Zod, Valibot, or ArkType to ensure the response actually matches the expected shape.
Generating Types from a Real API Response
The manual process of writing types for API responses is tedious and error-prone, especially for large, deeply nested responses. The typical workflow is:
- Make a request to the API endpoint (using a tool like curl, Postman, or your browser's DevTools network tab).
- Copy the JSON response body.
- Paste it into a JSON-to-TypeScript converter.
- Review and adjust the generated types (rename interfaces, fix nullability).
- Add the types to your project's type definition files.
This process takes seconds with an automated tool compared to minutes or hours of manual typing for complex responses. More importantly, the automated approach is less error-prone because the types are derived directly from real data.
Common Pitfalls
- Date strings — JSON has no date type. Dates come as strings (typically ISO 8601 format). Type them as
string, notDate. Parse them to Date objects explicitly where needed. - Numeric strings — some APIs return numbers as strings (e.g.,
"price": "19.99"). Type them asstringto match the actual response, and convert to numbers in your application logic. - Empty arrays — a JSON-to-TypeScript generator cannot infer the element type from an empty array. You will get
unknown[]orany[]. Always test with populated data. - Inconsistent responses — some APIs return different shapes for success and error cases, or different fields depending on query parameters. Generate types from multiple representative responses to capture the full shape.
Generate Types Instantly with PulpMiner
PulpMiner's API to TypeScript tool takes any JSON payload and produces clean, well-structured TypeScript interfaces instantly. Paste your API response, and get properly nested interfaces with correct null handling, ready to drop into your project.
