Chapter 9.5☕ 18 min read

Fetch API Typing

fetch() gives you a typed envelope (Response), but you must type the letter inside (JSON data).

01The Unknown Parcel

Every modern web application fetches data from APIs. The fetch() API is the standard way to make HTTP requests in JavaScript, and TypeScript provides type definitions for it. But there's a critical nuance that many developers miss: fetch() types the RESPONSE, not the DATA. Think of it like receiving a parcel delivery in Hyderabad.

When a delivery person arrives at your doorstep, they hand you a package. The package has labels — sender address, tracking number, delivery status, dimensions. These labels are the Response object: .status (200 = delivered, 404 = not found), .ok (was delivery successful?), .headers (package metadata). You can inspect the package details, but you CANNOT see what's inside until you open it.

Opening the package is response.json(). But here's the catch — the delivery person doesn't know what's inside. Is it biryani? Is it a book? Is it a phone charger? The label doesn't tell you the contents. response.json() returns Promise<any> because TypeScript — like the delivery person — has NO IDEA what the server put in the JSON body. It's an unknown parcel. You must TELL TypeScript what's inside by typing the result: const user: User = await response.json().

This isn't a limitation — it's reality. TypeScript runs at COMPILE TIME in the developer's machine. It cannot read the server's response at compile time to know the data shape. The type annotation is your contract with the compiler: "Trust me, the server sends a User object." And for production apps, you should VALIDATE that contract at runtime too. Because the server might change, the API might break, and your type assertion won't save you from a runtime data mismatch.

02Fetch Response Typing

Let's break down the full type signature of fetch:

// The basic fetch call
const res = await fetch(url);
// res: Response

// Response type has these key properties:
res.ok       // boolean — status 200-299
res.status   // number — HTTP status code
res.statusText // string — status message
res.headers  // Headers — response headers
res.url      // string — final URL (after redirects)
res.type     // ResponseType — basic, cors, etc.

// Methods that parse the body:
res.json()   // Promise<any>
res.text()   // Promise<string>
res.blob()   // Promise<Blob>
res.arrayBuffer() // Promise<ArrayBuffer>
res.formData()    // Promise<FormData>

Notice that fetch itself is well-typed. The Response object has full type definitions. The problem is that the body parsing methods (.json(), .text()) return generic types because TypeScript cannot know the server's response shape.

The most basic way to get typed data from fetch is a type assertion:

interface User {
  id: number;
  name: string;
  email: string;
}

async function getUser(
  id: number
): Promise<User> {
  const res =
    await fetch(`/api/users/${id}`);

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

  // Type assertion — TELLS TypeScript
  // what the response contains
  const user =
    (await res.json()) as User;

  return user;
}

You can also annotate the variable directly, which is cleaner:

const res =
  await fetch("/api/user");
const user: User =
  await res.json();

But remember: type assertions and annotations only affect COMPILE TIME. At runtime, the actual JSON might not match the User type. If the API returns { name: "Imran" } without an email field, your code compiles fine but user.email is undefined at runtime. For production apps, you should add runtime validation.

03Safe Response Validation

Type assertions are convenient, but they're a compile-time-only promise. For production code that handles critical data, you should validate the actual response shape at runtime. Here are the most common approaches:

Approach 1: Custom Type Guard (No extra dependencies)

function isUser(data: unknown):
  data is User {
  return typeof data === "object" &&
    data !== null &&
    typeof (data as any).id === "number" &&
    typeof (data as any).name === "string" &&
    typeof (data as any).email === "string";
}

async function getSafeUser(
  id: number
): Promise<User> {
  const res = await fetch(
    `/api/users/${id}`
  );
  if (!res.ok) {
    throw new Error("Fetch failed");
  }

  const data: unknown =
    await res.json();

  if (!isUser(data)) {
    throw new Error(
      "Invalid user data"
    );
  }

  // data is User here — guaranteed!
  return data;
}

Approach 2: Zod (Type-safe runtime validation)

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(
  id: number
): Promise<User> {
  const res = await fetch(
    `/api/users/${id}`
  );
  const data = await res.json();
  return UserSchema.parse(data);
  // Throws if shape doesn't match!
}

Approach 3: Wrapping fetch in a typed utility

async function fetchJson<T>(
  url: string
): Promise<T> {
  const res = await fetch(url);

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

  return res.json() as Promise<T>;
}

// Usage — T is inferred or explicit
const user =
  await fetchJson<User>(
    "/api/user"
  );
const users =
  await fetchJson<User[]>(
    "/api/users"
  );

The best approach depends on your needs. For simple scripts and prototypes, type assertions are fine. For production apps with critical data, use a combination: annotate the type for editor support AND validate at runtime with Zod or a type guard to catch API changes early.

04Fetch API Traps

Fetch typing has several common pitfalls that can lead to subtle bugs or runtime crashes.

Trap 1: Not Checking res.ok

HTTP errors like 404 or 500 are still successful HTTP requests. fetch() only rejects on network errors. Always check res.ok before parsing:

const res = await fetch(url);
// res.ok is false for 404/500!
// res.json() might still parse
// an error HTML page!
if (!res.ok) {
  throw new Error(
    `HTTP ${res.status}`
  );
}
const data = await res.json();

Trap 2: The Implicit any on res.json()

Without type annotation, res.json() returns Promise<any>. This means you can access ANY property without TypeScript complaining — even properties that don't exist:

const data = await res.json();
// data is any — no type checking!
console.log(
  data.nonexistent.property
); // No compile error, runtime crash!

Trap 3: Over-asserting the Type

Asserting a type doesn't make the data match. If the API changes, your assertion is wrong but TypeScript trusts you:

// Server now returns { username: string }
// but you still assert User:
const user =
  (await res.json()) as User;
// user.name is undefined at runtime!
// TypeScript didn't catch it.

Trap 4: Forgetting to Handle Headers

res.headers requires .get() method — it's not a plain object:

const contentType =
  res.headers["content-type"];
// ❌ Wrong! headers is Headers object

const contentType =
  res.headers.get("content-type");
// ✅ Correct!
05Fetch Typing Cheatsheet

Here's your complete cheatsheet for typing the Fetch API in TypeScript. Pin this to your mental board!

Basic Types:

fetch(url)  → Promise<Response>
res.json()  → Promise<any>
res.text()  → Promise<string>
res.ok      → boolean
res.status  → number

Typed Fetch Pattern:

async function get<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

// Usage
const user = await get<User>("/api/user");

Safe Fetch with Validation:

async function getSafe<T>(
  url: string,
  validate: (data: unknown) => data is T
): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data: unknown = await res.json();
  if (!validate(data)) throw new Error("Invalid data");
  return data;
}

Key Rules:

  • fetch() types the Response, NOT the JSON data — always type the json() result
  • Check res.ok before parsing JSON — HTTP errors don't reject the promise
  • Use type assertions (as T) or variable annotations for compile-time types
  • Add runtime validation (Zod or type guards) for production-grade safety
  • Wrap fetch calls in try/catch to handle network errors
  • Use res.headers.get() — headers is a Headers object, not a plain object

The Golden Rule: "fetch() is like receiving a parcel in Hyderabad — the label (Response) is typed, but the contents (JSON) are unknown. You must tell TypeScript what's inside. And for important deliveries, open and inspect the package at runtime too — don't just trust the label, bhai!"

Key Takeaways

  • fetch() returns Promise — the response object is typed, but the JSON data is not
  • response.json() returns Promise — you must type the result explicitly
  • Always check res.ok before parsing JSON — HTTP errors don't reject the promise
  • Type assertions (as T) are compile-time only — add runtime validation for critical data
  • Use a generic fetchJson wrapper for reusable typed fetch calls
  • Wrap fetch in try/catch to handle network errors gracefully
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase