Typing APIs & Fetch
When you call fetch() in TypeScript, the return type is Promise<Response>.
That is intentionally opaque — TypeScript has no idea what shape the JSON body holds.
This page walks through every layer: why Response.json() returns Promise<unknown>,
how to safely cast or validate the body, building a reusable generic fetcher, handling
errors with discriminated unions, and adding abort-signal and header typing.
The Problem: fetch Returns Unknown Data
The core challenge is that HTTP is dynamic. At compile time TypeScript cannot know what the server will actually send. The built-in types reflect this honestly:
// fetch signature (simplified from lib.dom.d.ts)
declare function fetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response>;
// Response.json() returns Promise<unknown> in TypeScript 4.x+ strict mode
// (older versions used Promise<any> — unknown is the safer default)
const response = await fetch('/api/users');
const data = await response.json(); // type: unknown
// You cannot access properties on unknown without narrowing or assertion
data.users; // Error: Object is of type 'unknown'Response.json() returned Promise<any>. Since TypeScript 4.3 the return type was changed to Promise<unknown> in newer lib.dom.d.ts versions. Either way, treat the result as unknown — never trust raw any from network calls.Simple Type Assertion (Fast but Unsafe)
The quickest approach is a type assertion. You tell TypeScript "trust me, this is a
User". It compiles with zero overhead, but you get no runtime guarantee.
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error(`HTTP error: ${res.status}`);
}
// Cast from unknown to User — no validation at runtime
return res.json() as Promise<User>;
}
const user = await fetchUser(1);
console.log(user.name); // TypeScript is happy, but if the API changes this breaks silentlyas User is a lie you tell the compiler. If the server sends a different shape (a bug, a version change, a 404 HTML page), your code will silently fail at runtime with confusing errors. Use assertions only when you fully control the API and are willing to accept that risk.Typing JSON Responses with Interfaces
Define clear interfaces for every API response your app consumes. This creates a contract between your frontend code and the API, and it documents the expected shape in the most useful place — the fetch call itself.
// api-types.ts — your domain types
export interface User {
id: number;
name: string;
email: string;
createdAt: string; // ISO-8601 from JSON
role: 'admin' | 'member' | 'guest';
}
export interface Post {
id: number;
title: string;
body: string;
authorId: number;
tags: string[];
}
export interface PaginatedResponse<T> {
data: T[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
// Usage
async function fetchUsers(page = 1): Promise<PaginatedResponse<User>> {
const res = await fetch(`/api/users?page=${page}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json() as Promise<PaginatedResponse<User>>;
}
const result = await fetchUsers(2);
console.log(result.totalPages); // number ✓
result.data.forEach(user => console.log(user.email)); // string ✓Generic Fetcher Function
Rather than repeating the same fetch + if (!res.ok) throw pattern everywhere, extract
a typed generic fetcher. This is the foundation most real-world apps build on.
interface FetchOptions extends RequestInit {
baseUrl?: string;
}
async function fetcher<T>(url: string, options: FetchOptions = {}): Promise<T> {
const { baseUrl = '', ...init } = options;
const fullUrl = baseUrl ? `${baseUrl}${url}` : url;
const res = await fetch(fullUrl, init);
if (!res.ok) {
// Attempt to parse error body for better messages
const errorBody = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${res.statusText}. Body: ${errorBody}`);
}
// Handle 204 No Content — no body to parse
if (res.status === 204) {
return undefined as unknown as T;
}
return res.json() as Promise<T>;
}
// Fully typed callers
const user = await fetcher<User>('/api/users/1');
const posts = await fetcher<Post[]>('/api/posts');
const paginated = await fetcher<PaginatedResponse<User>>('/api/users?page=1');fetcher<User>) so inference flows correctly. If you omit it, TypeScript infers unknown, which forces you to narrow before using the result.Error Handling with Discriminated Unions
Throwing errors makes caller code verbose and easy to forget. A cleaner pattern is to
return a discriminated union — a Result type that explicitly models both success and
failure. This makes error handling impossible to forget because TypeScript will complain
if you access .data without first checking .ok.
// Result type — discriminated by the 'ok' field
type Success<T> = { ok: true; data: T; status: number };
type Failure = { ok: false; error: string; status: number };
type Result<T> = Success<T> | Failure;
async function safeFetch<T>(url: string, init?: RequestInit): Promise<Result<T>> {
try {
const res = await fetch(url, init);
if (!res.ok) {
const errorText = await res.text().catch(() => res.statusText);
return { ok: false, error: errorText, status: res.status };
}
const data = (await res.json()) as T;
return { ok: true, data, status: res.status };
} catch (err) {
// Network error, CORS, DNS failure, etc.
const message = err instanceof Error ? err.message : 'Unknown network error';
return { ok: false, error: message, status: 0 };
}
}
// Caller is forced to handle both branches
const result = await safeFetch<User>('/api/users/1');
if (!result.ok) {
console.error(`Error ${result.status}: ${result.error}`);
} else {
// TypeScript knows result.data is User here
console.log(result.data.name);
}Result<T> must check ok before accessing data — TypeScript enforces it through narrowing.Manual Runtime Validation
The most robust approach is to validate the response shape at runtime. You can write simple guard functions without any library dependencies.
// Type guard: validates a raw unknown value is a User
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value && typeof (value as any).id === 'number' &&
'name' in value && typeof (value as any).name === 'string' &&
'email' in value && typeof (value as any).email === 'string'
);
}
async function fetchValidatedUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw: unknown = await res.json();
if (!isUser(raw)) {
throw new Error(`Invalid user shape from API: ${JSON.stringify(raw)}`);
}
return raw; // TypeScript now knows this is User
}
// For arrays, validate each element
function isUserArray(value: unknown): value is User[] {
return Array.isArray(value) && value.every(isUser);
}Schema Validation with Zod
Writing manual guards is tedious. Zod is the de-facto standard for runtime validation in TypeScript projects. It infers TypeScript types from schemas automatically, so your runtime check and compile-time type stay in perfect sync.
import { z } from 'zod';
// Define schema once — Zod infers the TypeScript type
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
role: z.enum(['admin', 'member', 'guest']),
});
// Infer the TypeScript type from the schema (no duplication)
type User = z.infer<typeof UserSchema>;
const PaginatedUsersSchema = z.object({
data: z.array(UserSchema),
page: z.number(),
pageSize: z.number(),
totalCount: z.number(),
totalPages: z.number(),
});
type PaginatedUsers = z.infer<typeof PaginatedUsersSchema>;
// Fetcher that validates with Zod
async function fetchUsers(page = 1): Promise<PaginatedUsers> {
const res = await fetch(`/api/users?page=${page}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.json();
// parse() throws ZodError if shape is wrong
return PaginatedUsersSchema.parse(raw);
}schema.parse() throws on invalid data. Use schema.safeParse() to get a { success, data, error } result without throwing — similar to the discriminated-union pattern shown earlier.Abort Signals
Every real-world fetcher should support cancellation. The AbortController API is fully
typed in TypeScript via the DOM lib. Passing signal to fetch lets you cancel
in-flight requests (e.g. when a React component unmounts).
async function fetchWithAbort<T>(
url: string,
signal: AbortSignal,
init?: RequestInit
): Promise<T> {
const res = await fetch(url, { ...init, signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
}
// Create a controller and pass its signal
const controller = new AbortController();
const signal: AbortSignal = controller.signal;
const fetchPromise = fetchWithAbort<User[]>('/api/users', signal);
// Cancel after 5 seconds
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const users = await fetchPromise;
clearTimeout(timeout);
console.log(users);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
console.log('Request cancelled');
} else {
throw err;
}
}
// React example (useEffect cleanup)
// useEffect(() => {
// const ctrl = new AbortController();
// fetchWithAbort<User[]>('/api/users', ctrl.signal).then(setUsers);
// return () => ctrl.abort(); // cancels on unmount
// }, []);Typing Headers
The RequestInit.headers field accepts HeadersInit, which is a union of three forms.
TypeScript narrows each form differently.
// HeadersInit = Headers | string[][] | Record<string, string>
// Form 1: plain object (most common)
const headersObj: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
};
// Form 2: 2D string array
const headersArr: string[][] = [
['Content-Type', 'application/json'],
['Authorization', `Bearer ${token}`],
];
// Form 3: Headers instance (allows duplicates for cookies etc.)
const headers = new Headers();
headers.set('Content-Type', 'application/json');
headers.append('Set-Cookie', 'session=abc');
// Typed helper to build auth headers
function authHeaders(token: string): Record<string, string> {
return {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${token}`,
};
}
const res = await fetch('/api/protected', {
headers: authHeaders(myToken),
});Typed API Client Pattern
For larger projects, centralise all API calls into a typed client class or module. This makes it easy to swap the base URL, add auth tokens globally, and mock in tests.
class ApiClient {
private baseUrl: string;
private token: string | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
setToken(token: string): void {
this.token = token;
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
async get<T>(path: string, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'GET',
headers: this.buildHeaders(),
signal,
});
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
return res.json() as Promise<T>;
}
async post<TBody, TResponse>(
path: string,
body: TBody,
signal?: AbortSignal
): Promise<TResponse> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
signal,
});
if (!res.ok) throw new Error(`POST ${path} failed: ${res.status}`);
return res.json() as Promise<TResponse>;
}
async put<TBody, TResponse>(path: string, body: TBody): Promise<TResponse> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'PUT',
headers: this.buildHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PUT ${path} failed: ${res.status}`);
return res.json() as Promise<TResponse>;
}
async delete(path: string): Promise<void> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'DELETE',
headers: this.buildHeaders(),
});
if (!res.ok) throw new Error(`DELETE ${path} failed: ${res.status}`);
}
}
// Instantiate once, reuse everywhere
export const api = new ApiClient('https://api.example.com');
// Usage — fully typed
api.setToken(sessionToken);
const user = await api.get<User>(`/users/${userId}`);
const newPost = await api.post<Omit<Post, 'id'>, Post>('/posts', {
title: 'Hello',
body: 'World',
authorId: 1,
tags: ['typescript'],
});Async Error Handling Summary
Approach | Type Safety | Runtime Safety | Complexity |
|---|---|---|---|
Type assertion (as T) | Compile-time only | None | Low |
Manual type guard | Full | Good | Medium |
Zod schema | Full (inferred) | Excellent | Low (library dep) |
Discriminated union Result | Full | Good | Medium |
Quick Reference
fetch()returnsPromise<Response>— the body type is always unknownResponse.json()returnsPromise<unknown>— never treat it asanyUse
as Promise<T>for quick assertions when you control the APIWrite type guards (
value is T) for critical pathsUse Zod for full parse-and-infer without code duplication
Return
Result<T>discriminated unions to make error handling mandatoryAlways pass
AbortSignalto support cancellationCentralise fetch logic in an
ApiClientclass for maintainability