The short answer
Check response.ok, then put a @type on the variable that receives await response.json(). Give the function a @returns so callers get the type too:
/**
* @typedef {object} Product
* @property {string} sku
* @property {string} name
* @property {number} cents
*/
/**
* @param {string} query
* @returns {Promise<Product[]>}
*/
export async function searchProducts(query) {
const response = await fetch(`/api/products?q=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
/** @type {{ products: Product[] }} */
const body = await response.json();
return body.products;
}
const results = await searchProducts("lamp");
console.log(results.map((product) => product.name.toUpperCase()));interface Product {
sku: string;
name: string;
cents: number;
}
export async function searchProducts(query: string): Promise<Product[]> {
const response = await fetch(`/api/products?q=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
const body: { products: Product[] } = await response.json();
return body.products;
}
const results = await searchProducts("lamp");
console.log(results.map((product) => product.name.toUpperCase()));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"types": []
},
"include": ["*.js"]
} body.products is a Product[], so the map callback knows product.name is a string.
Why it works
Response.json() is declared as returning Promise<any>: the server can send anything. An any value can be assigned to any type, so the @type comment on body is accepted, and from there on everything is checked. That @type is a statement about the API, not a check of the response. For data you do not control, parse into unknown and narrow it with a type guard, the way the JSON.parse recipe does.
The same thing in TypeScript
The TypeScript equivalent tab writes const body: { products: Product[] } = await response.json(). Same claim, same lack of runtime checking.
What goes wrong
Forget the await on json(), and you are reading a property of a promise (TS2339). Annotate the body as unknown and skip the checks, and every property read is TS18046:
export async function searchProducts() {
const response = await fetch("/api/products?q=lamp");
console.log(response.json().products);error TS2339: Property 'products' does not exist on type 'Promise<any>'.
/** @type {unknown} */
const body = await response.json();
return body.products;error TS18046: 'body' is of type 'unknown'.
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"types": []
},
"include": ["*.js"]
} Gotchas
fetchdoes not reject on HTTP errors. A 404 or 500 still resolves; checkresponse.ok(orresponse.status) before parsing.json()can be read once. The body is a stream; a secondresponse.json()call rejects. Store the parsed value.- An empty 204 response has no JSON.
response.json()rejects on an empty body; check the status before parsing.
Questions
Can I write fetch<Product[]>(url) like some libraries allow?
No. fetch is not generic in TypeScript's DOM types, and type arguments cannot be written in a .js file anyway. Type the parsed body, or wrap fetch in a small function with a @template and a @returns {Promise<T>}.
Does the same code work in Node.js?
Yes. Node.js 18 and later have a global fetch, and @types/node declares it; response.json() returns Promise<any> there too, so the same @type annotation applies.
Sources
- Response: json() method · MDN Web Docs
- Response: ok property · MDN Web Docs
Last reviewed 21 October 2026.