TypeScriptTyping APIs & Fetch

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:

TS
// 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'
Note
In TypeScript 4.2 and earlier, 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.

TS
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 silently
Warning
A type assertion as 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.

TS
// 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.

TS
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');
Tip
Pass the type argument at the call site (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.

TS
// 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);
}
Success
This pattern eliminates silent failures. Every caller that receives a 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.

TS
// 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.

TS
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);
}
Note
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).

TS
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.

TS
// 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.

TS
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() returns Promise<Response> — the body type is always unknown

  • Response.json() returns Promise<unknown> — never treat it as any

  • Use as Promise<T> for quick assertions when you control the API

  • Write type guards (value is T) for critical paths

  • Use Zod for full parse-and-infer without code duplication

  • Return Result<T> discriminated unions to make error handling mandatory

  • Always pass AbortSignal to support cancellation

  • Centralise fetch logic in an ApiClient class for maintainability