Recipe · Node and platforms

How do I type the JSON from a fetch response in JavaScript?

  • Browser (no bundler)
  • fetch
  • Response.json
  • @type
  • Checked with TypeScript 5.9

The problem await response.json() gives me any, so a typo in a field name is never caught. How do I type what my API returns in a JSDoc-checked fetch call?

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:

products.js products.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * @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()));
Check the response, then type what json() returns.
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:

products.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: products.js(4,31): error TS2339: Property 'products' does not exist on type 'Promise<any>'. products.js(9,10): error TS18046: 'body' is of type 'unknown'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. products.js(4,31): error TS2339: Property 'products' does not exist on type 'Promise<any>'. products.js(9,10): error TS18046: 'body' is of type 'unknown'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. products.js(4,31): error TS2339: Property 'products' does not exist on type 'Promise<any>'. products.js(9,10): error TS18046: 'body' is of type 'unknown'. checked 21 October 2026
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'.
}
json() without await, and a body read as unknown before it is checked.
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

  • fetch does not reject on HTTP errors. A 404 or 500 still resolves; check response.ok (or response.status) before parsing.
  • json() can be read once. The body is a stream; a second response.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

Last reviewed 21 October 2026.