Skip to main content

Fetch API TypeScript: Typing Async Responses

When you fetch data from an API in React, the response JSON is untyped by default—it could be anything. TypeScript's fetch API returns a Promise<Response>, and you must call .json() to extract the body, which returns Promise<any>. To gain type safety, you define an interface representing the API response shape and cast the JSON to that type. Combined with runtime validation libraries like Zod, you can ensure the actual response matches your expectations before using it in your component.

How Do You Type a Fetch Response in TypeScript?

To type a fetch response, define an interface for the expected JSON structure, then cast the result of .json() to that type. The most common pattern is to create a separate types.ts file with all API response types, then import them in your component. This keeps your types organized and reusable across components.

import { useState, useEffect } from 'react';

// Define the API response type
interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}

export const UsersList = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const fetchUsers = async () => {
try {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = (await response.json()) as User[];
setUsers(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};

fetchUsers();
}, []);

if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;

return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
);
};

The as User[] tells TypeScript to treat the result as an array of users. However, this assumes the API returns exactly what you expect. If the response is malformed, your code might crash at runtime. For production code, use runtime validation (see later articles in this series).

Creating a Generic Fetch Utility

Instead of repeating the same fetch logic in every component, create a reusable utility function:

// api.ts
interface FetchOptions extends RequestInit {
timeout?: number;
}

async function fetchData<T>(
url: string,
options: FetchOptions = {}
): Promise<T> {
const { timeout = 10000, ...fetchOptions } = options;

const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});

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

const data = (await response.json()) as T;
return data;
} finally {
clearTimeout(id);
}
}

// Use the generic utility
export const UserListWithUtility = () => {
const [users, setUsers] = useState<User[]>([]);

useEffect(() => {
const loadUsers = async () => {
try {
const data = await fetchData<User[]>('https://api.example.com/users');
setUsers(data);
} catch (err) {
console.error('Failed to fetch users:', err);
}
};

loadUsers();
}, []);

return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};

This utility handles timeouts and error checking. The generic <T> parameter lets you fetch any type of data without repeating code. TypeScript ensures you pass a URL that you're fetching, and the return type is whatever T you specify.

Typing Paginated API Responses

Many APIs return paginated responses with metadata. Define a wrapper type that includes both data and pagination info:

// Define a paginated response wrapper
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}

interface Product {
id: number;
name: string;
price: number;
}

export const ProductsPaginated = () => {
const [page, setPage] = useState<number>(1);
const [response, setResponse] = useState<PaginatedResponse<Product> | null>(null);
const [loading, setLoading] = useState<boolean>(false);

const fetchProducts = async (pageNum: number) => {
setLoading(true);
try {
const data = await fetchData<PaginatedResponse<Product>>(
`https://api.example.com/products?page=${pageNum}`
);
setResponse(data);
} finally {
setLoading(false);
}
};

useEffect(() => {
fetchProducts(page);
}, [page]);

if (loading) return <p>Loading...</p>;
if (!response) return <p>No data</p>;

return (
<div>
<ul>
{response.items.map(product => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
<p>Page {response.page} of {Math.ceil(response.total / response.pageSize)}</p>
<button onClick={() => setPage(page + 1)} disabled={!response.hasMore}>
Next
</button>
</div>
);
};

The PaginatedResponse<T> is a generic wrapper that works with any data type. When you fetch products, it becomes PaginatedResponse<Product>, ensuring all fields are typed correctly.

Typing POST Requests with Request Bodies

When you send data to an API, you type the request body and the response separately:

interface CreateUserRequest {
name: string;
email: string;
}

interface CreateUserResponse {
id: number;
name: string;
email: string;
createdAt: string;
}

async function createUser(data: CreateUserRequest): Promise<CreateUserResponse> {
return fetchData<CreateUserResponse>(
'https://api.example.com/users',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}
);
}

export const CreateUserForm = () => {
const [formData, setFormData] = useState<CreateUserRequest>({
name: '',
email: '',
});
const [result, setResult] = useState<CreateUserResponse | null>(null);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
const newUser = await createUser(formData);
setResult(newUser);
} catch (err) {
console.error('Failed to create user:', err);
}
};

return (
<div>
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={e => setFormData(prev => ({ ...prev, name: e.currentTarget.value }))}
placeholder="Name"
/>
<input
value={formData.email}
onChange={e => setFormData(prev => ({ ...prev, email: e.currentTarget.value }))}
placeholder="Email"
type="email"
/>
<button type="submit">Create User</button>
</form>
{result && <p>User created: {result.name}</p>}
</div>
);
};

By separating request and response types, you make it clear what data you're sending and what you expect back. This is especially useful for documentation and code review.

Key Takeaways

  • Define interfaces for API response shapes and cast fetch results using the as keyword.
  • Create a generic fetchData<T>() utility to avoid repeating fetch logic and error handling.
  • Use generic wrapper types like PaginatedResponse<T> to handle common response patterns.
  • Type both request bodies and responses separately for clarity and type safety.
  • Handle errors with try/catch and check response.ok before assuming success.

Frequently Asked Questions

Why not just use any for the fetch response?

Using any disables type checking. You lose the ability to catch typos when accessing response properties, and you won't know if the API changes. TypeScript won't warn you of breaking changes.

Should I validate the fetch response at runtime?

Yes, if the API is external or you don't control the response format. Use Zod or a similar library to validate the actual response matches your expected type. This is covered in a later article.

How do I handle fetch timeouts?

Use an AbortController to cancel the request after a timeout (as shown in the utility function). This prevents requests from hanging indefinitely.

Can I use async/await with fetch, or do I need to stick with .then()?

Async/await is preferred in modern React code. It's cleaner, easier to read, and makes error handling more straightforward with try/catch.

How do I cancel a fetch request if a component unmounts?

Use the cleanup function in useEffect to call controller.abort() when the component unmounts. The fetch utility above includes this pattern with AbortController.

Further Reading