TypeScript Typing fetch() Responses
The browser’s fetch() API is untyped where it matters most: it knows a Response object is coming back, but it has no idea what shape of data lives inside the JSON body. TypeScript can’t read your server’s mind, so by default the data you get back from a fetch call is typed as any — which quietly turns off type checking for everything downstream. This lesson shows you how to reclaim that safety: how to annotate fetch results, build reusable generic fetch helpers, and validate data at runtime so your types actually match what the network sends.
Assumed background: this lesson assumes you’re comfortable with JavaScript’s fetch(), promises, and async/await. We focus purely on the TypeScript layer on top.
Overview: why fetch() needs help
fetch(url) returns a Promise<Response>. The Response type is defined in the DOM library (lib.dom.d.ts), and it’s fully typed — properties like ok, status, and statusText all have correct types. The trouble starts at response.json(). Its signature in the standard library is:
json(): Promise<any>;
That’s not a bug or an oversight — it’s the only honest signature TypeScript can give it. The compiler has no way to know what bytes a remote server will send back at runtime. It could be a user object, an error payload, an empty body, or malformed JSON. So the type system hands you any and steps aside, trusting you to supply the real shape.
The problem with any is that it’s contagious. Once a value is typed any, every property access, every assignment, and every function call involving that value skips type checking entirely. Typo a property name, treat a number as a string, forget a field — TypeScript won’t say a word. Typing fetch responses correctly is really about closing that gap as early as possible, right where the data enters your program.
There are two levels to “typing” a fetch response:
- Compile-time typing — telling TypeScript “trust me, this JSON matches this interface,” usually with a type assertion or a generic function. This restores autocomplete and catches typos in your own code, but does not check the actual data.
- Runtime validation — actually inspecting the parsed value to confirm it matches the shape you expect, using type guards or a validation library. This is the only way to catch a server that sends something unexpected.
Good fetch code usually uses both: a static type for developer ergonomics, and a validation step at the boundary where untrusted data enters your app.
Syntax
The general pattern for typing a single fetch call looks like this:
interface ShapeOfData {
// fields you expect back
}
async function loadData(url: string): Promise<ShapeOfData> {
const response = await fetch(url);
const data = await response.json() as ShapeOfData;
return data;
}
| Part | Meaning |
|---|---|
interface ShapeOfData |
Describes the JSON structure you expect the endpoint to return. |
fetch(url) |
Returns Promise<Response> — fully typed, but has no knowledge of the body’s contents. |
response.json() |
Returns Promise<any> — parses the body but cannot know its shape. |
as ShapeOfData |
A type assertion: tells the compiler to treat the value as this type. It performs no runtime check. |
For reusable code, you generalize the interface into a type parameter, producing a generic fetch helper (shown in Example 2 below).
Examples
Example 1: A basic typed fetch
Suppose an endpoint at /users/1 returns { "id": 1, "name": "Alice Smith", "email": "alice@example.com" }.
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
const data = await response.json() as User;
return data;
}
getUser(1).then((user) => {
console.log(`Fetched user: ${user.name} (${user.email})`);
});
Output:
Fetched user: Alice Smith (alice@example.com)
The User interface documents the expected shape, and as User tells the compiler to treat the parsed JSON as that shape. Inside .then(), user.name and user.email are fully typed strings with autocomplete — but note that if the server actually returned something different, nothing here would catch it.
Example 2: A generic, reusable fetch helper
Writing the same boilerplate for every endpoint gets old fast. A generic helper function lets you specify the type per call site.
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
interface Post {
id: number;
title: string;
body: string;
}
async function main(): Promise<void> {
const post = await fetchJson<Post>("https://api.example.com/posts/1");
console.log(`${post.title}: ${post.body}`);
}
main();
Output (assuming the endpoint returns { id: 1, title: "Hello World", body: "This is the first post." }):
Hello World: This is the first post.
fetchJson<T> is a single function you can reuse for any endpoint — you just supply the expected type as a type argument, like fetchJson<Post>(...). Notice it also checks response.ok before parsing: fetch() only rejects on network failures, never on HTTP error statuses like 404 or 500, so that check has to be done manually.
Example 3: Realistic error handling with a result type
Real applications usually want to distinguish “succeeded” from “failed” without throwing exceptions everywhere. A discriminated union is a clean way to model that.
interface Product {
id: number;
name: string;
price: number;
}
type FetchResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
async function fetchProducts(url: string): Promise<FetchResult<Product[]>> {
try {
const response = await fetch(url);
if (!response.ok) {
return { ok: false, error: `HTTP ${response.status}` };
}
const data = (await response.json()) as Product[];
return { ok: true, data };
} catch (err) {
return { ok: false, error: String(err) };
}
}
async function main(): Promise<void> {
const result = await fetchProducts("https://api.example.com/products");
if (result.ok) {
for (const product of result.data) {
console.log(`${product.name}: $${product.price}`);
}
} else {
console.error("Failed to load products:", result.error);
}
}
main();
Output (assuming the endpoint returns two products, Keyboard at $49 and Mouse at $25):
Keyboard: $49
Mouse: $25
The ok field acts as a discriminant: once you check if (result.ok), TypeScript narrows the union so that result.data is only accessible in the true branch and result.error only in the false branch. This pattern scales well because callers are forced by the type system to handle the failure case — there’s no way to reach result.data without checking ok first.
Under the hood: what the compiler actually does
It helps to be precise about what’s checked and what isn’t:
- Type erasure: every interface, type alias, and
asassertion disappears completely when TypeScript compiles to JavaScript. The compiled code that runs in the browser or Node.js has no notion ofUserorProduct— it’s just objects and property access. This means annotations can never change runtime behavior, only compile-time checking. as Tis a promise, not a check: a type assertion tells the compiler “stop checking, I know better.” It doesn’t call any validation function, and it doesn’t inspect the object’s actual keys. If the real JSON is missing a field or has the wrong type, the assertion happily lies to you, and the bug surfaces later as a runtime error (e.g.undefinedwhere you expected a string) far from where the fetch happened.- Generics carry the type through, they don’t create it: in
fetchJson<T>, the type parameterTonly exists so the compiler can propagate whatever type you request at the call site into the function’s return type. It has zero effect on whatresponse.json()actually parses. - Structural typing still applies: TypeScript doesn’t care that a
Userobject “came from the network” — once asserted, it’s treated exactly like any other object with matching properties. Extra properties on the real payload are silently ignored by your code (though excess property checks can still catch mistakes in literal object expressions elsewhere).
The upshot: the type checker verifies that your code is internally consistent given the type you asserted. It cannot, and does not try to, verify that the network actually sent that shape. That verification is your job, at runtime, if you need it.
Common Mistakes
Mistake 1: Passing a type argument to .json()
It’s tempting to think .json() accepts a generic type parameter the way a custom helper might:
interface User {
id: number;
name: string;
}
async function getUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
const data = await response.json<User>();
return data;
}
This fails with a real tsc error: Expected 0 type arguments, but got 1. The standard Response.json() method, as defined in lib.dom.d.ts, is not generic — it always returns Promise<any>. You must cast the result yourself instead:
interface User {
id: number;
name: string;
}
async function getUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
const data = await response.json() as User;
return data;
}
Mistake 2: Forgetting to await before asserting
response.json() returns a promise, not the parsed value directly. Asserting the promise itself as your target type is a mistake TypeScript actually catches:
interface User {
id: number;
name: string;
}
async function getUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
const data = response.json() as User; // missing await
return data;
}
tsc reports: Conversion of type 'Promise<any>' to type 'User' may be a mistake because neither type sufficiently overlaps with the other. The fix is simply to await the call before asserting, so you’re casting the resolved value rather than the pending promise:
interface User {
id: number;
name: string;
}
async function getUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
const data = await response.json() as User;
return data;
}
Best Practices
For data you don’t fully control (any external API), prefer validating the shape instead of blindly asserting it. Type the parsed JSON as unknown and narrow it with a type guard:
interface Product {
id: number;
name: string;
price: number;
}
function isProduct(value: unknown): value is Product {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "number" &&
typeof candidate.name === "string" &&
typeof candidate.price === "number"
);
}
async function fetchProduct(url: string): Promise<Product> {
const response = await fetch(url);
const data: unknown = await response.json();
if (!isProduct(data)) {
throw new Error("Unexpected response shape from server");
}
return data;
}
fetchProduct("https://api.example.com/products/1").then((product) => {
console.log(`${product.name} costs $${product.price}`);
});
Output (assuming the endpoint returns { id: 1, name: "Keyboard", price: 49 }):
Keyboard costs $49
Unlike an as assertion, isProduct actually inspects the object at runtime and only lets execution continue past the check if the fields genuinely match. Combine this pattern with the general guidance below:
- Never assume an
asassertion validates anything — it is purely a compiler instruction and produces zero runtime code. - Always check
response.okbefore parsing the body;fetch()resolves normally for 4xx/5xx responses and only rejects on network-level failures. - Type the intermediate result as
unknown, notany, when you plan to validate it —unknownforces you to narrow before using it, whereasanylets mistakes slip through silently. - For anything beyond a toy project, use a runtime schema library (such as Zod or io-ts) instead of hand-written type guards — they generate both the static type and the validator from one schema, so they can’t drift out of sync.
- Wrap `fetch` + `.json()` in a small typed helper (like
fetchJson<T>) so error handling and response checks live in one place instead of being repeated at every call site. - Wrap network calls in
try/catch(or a result type) to handle offline errors, timeouts, and malformed JSON gracefully instead of letting a rejected promise crash the caller.
Practice Exercises
- Exercise 1: Write an interface
Weatherwith fieldscity: string,tempC: number, andconditions: string. Write an async functiongetWeather(city: string): Promise<Weather>that fetches from`https://api.example.com/weather?city=${city}`and returns the typed result. - Exercise 2: Generalize your solution into a reusable
fetchJson<T>(url: string): Promise<T>helper that throws an error whenresponse.okisfalse, then rewritegetWeatherto use it. - Exercise 3: Write a type guard
isWeather(value: unknown): value is Weatherthat checks all three fields at runtime, and use it insidegetWeatherto throw a descriptive error instead of trusting anas Weatherassertion.
Summary
fetch()returns a fully-typedPromise<Response>, butresponse.json()always returnsPromise<any>because the compiler cannot know the server’s actual payload shape.- Use an
interfaceortypeplus a type assertion (as T) to restore static typing after parsing — but remember an assertion performs no runtime check. - Generic helper functions like
fetchJson<T>(url: string): Promise<T>let you reuse the same fetch-and-check logic across every endpoint in an app. fetch()only rejects on network failure; always checkresponse.okto detect HTTP error statuses.- All types are erased at compile time — the compiled JavaScript has no knowledge of your interfaces, so nothing at runtime stops mismatched data from flowing through an assertion.
- For real safety with untrusted data, type the parsed value as
unknownand validate it with a type guard or a schema library before trusting it asT.
