cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

Typing fetch & JSON

See why response.json() gives you any, treat server data as unknown, check it with your own code or a schema like Zod, and trust only what passed.

By the end, you can
  • 01
    Spot untyped dataExplain why response.json() and JSON.parse give any, and why a type annotation on them checks nothing.
  • 02
    Check data before you use itNarrow an unknown reply step by step, and write a function that checks it and gives back a real type.
  • 03
    Use a schemaDescribe a reply once with a schema like Zod, and get both the check and the type from it.

A server reply has no type

A shop page loads a product from its server with fetch, then reads the reply's body as JSON with response.json() (the fetch & JSON lesson covers both). Here the server sends { "name": "Tea", "price": 3 }, and the code has a typo:

A typo TypeScript doesn't catchPop out in the code editor (opens in a new tab)TypeScript
async function showProduct() {  const response = await fetch("product.json");  const product = await response.json();  //    ^?  console.log(`${product.nmae} costs $${product.price}`);} showProduct();

Line 2 asks the server for product.json, and line 3 reads the reply as JSON. Line 5 misspells name as nmae, and TypeScript says nothing. The code prints undefined costs $3. The answer after the ^? on line 4 shows why: product is any, the type that turns checking off (the any, unknown & never lesson covers it).

Now change one thing: say on line 3 that the reply is unknown, the type for "a value you haven't checked yet".

The same reply as unknown (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
async function showProduct() {  const response = await fetch("product.json");  const product: unknown = await response.json();  console.log(`${product.nmae} costs $${product.price}`);} showProduct();

Line 4 gets two errors, on purpose: 'product' is of type 'unknown'. That means "you haven't shown me what this is yet, so you can't read anything from it". Both reads are marked, the typo and the correct price. TypeScript isn't being difficult: it really can't know what the server sent. Your code has to look first. The rest of this lesson is about how.

A server window sends a card that says "price": "4". An arrow labelled json() leads into a window called your code, where the reply is a card marked unknown. An arrow labelled check leads to a card marked Product.server"price": "4"sends text, any shapejson()your codeunknownProductchecknothing known yetsafe to use
The reply arrives while the page runs, so TypeScript can't see inside it. Treat it as unknown where it enters, check it once, and the rest of your code works with a real Product.
Real-life analogyCustoms at the border

A parcel from abroad has a label, but anyone can write a label. So customs opens it once, at the border, and checks what's really inside. After that, nobody along the way has to check it again.

In real life: A parcel arrives from another country
In TypeScript: A reply arrives from a server
In real life: Its label says "books", but nobody has looked inside
In TypeScript: response.json() gives any: nothing has looked at the data
In real life: Customs opens it and checks it at the border
In TypeScript: Your code checks the data where it enters, as unknown
In real life: After customs, it travels freely inside the country
In TypeScript: After the check, the rest of your code uses a real Product
The main idea

Data from a server has no type until your code checks it. response.json() gives you any, which TypeScript doesn't check at all. Treat the reply as unknown, check it once where it arrives, and only then give it a real type like Product.

response.json() gives you any

TypeScript's browser types describe fetch fully: it gives back a Promise of a Response. But a response's body is only known when it arrives, so they say json() gives back a promise of any. (JSON.parse gives any too, for the same reason.) And any spreads: everything you read from it is any as well. This time the server sends the cake's price as text, { "name": "Cake", "price": "4" }:

any spreads to every propertyPop out in the code editor (opens in a new tab)TypeScript
async function showPrice() {  const response = await fetch("cake.json");  //    ^?  const product = await response.json();  const price = product.price;  //    ^?  console.log(price.toFixed(2));} showPrice().catch((error) => console.log(`Crashed: ${error.message}`));

The answer on line 3 shows TypeScript knows response is a Response. Line 4 reads the JSON, and line 5 reads its price. The answer on line 6 shows price is any too. So line 7 can call price.toFixed(2), which formats a number with two decimals, and TypeScript has no complaint. But the price is the text "4", and text has no toFixed. When it runs, the code crashes, and line 10's catch prints Crashed: price.toFixed is not a function.

The obvious fix is to write the type you expect. It looks safer:

A type written on the reply, and no checkPop out in the code editor (opens in a new tab)TypeScript
type Product = { name: string; price: number }; async function showPrice() {  const response = await fetch("cake.json");  const product: Product = await response.json();  //    ^?  console.log(product.price.toFixed(2));} showPrice().catch((error) => console.log(`Crashed: ${error.message}`));

Line 1 describes a Product, and line 5 says the reply is one. The answer on line 6 shows const product: Product, and line 7 has no errors. But nothing changed while the page runs: the price is still text, and the code still prints Crashed: product.price.toFixed is not a function. An any can go into any type without a check, so the annotation is a promise you make, like as (the Type assertions lesson explains why). A helper like fetchJson<Product>() from Generics in practice makes the same promise in one place.

The table below lists the ways to receive JSON. Read each row as: what you write, what TypeScript believes, and what is really checked.

Five ways to receive a JSON reply
What you writeWhat TypeScript believesWhat's checked while the page runs
await response.json()any: every property and call is allowed.Nothing.
const product: Product = await response.json()Product, because you said so.Nothing.
await response.json() as ProductProduct, because you said so.Nothing.
const product: unknown = await response.json()Nothing yet: you have to check before you read.Only what your own checks look at.
parseProduct(await response.json())Product, because the check proved it.Every field the check looks at.

Treat the reply as unknown

unknown is the honest type for a reply: "this could be anything". TypeScript lets you put anything in it, but lets you use it only after a check proves what it is. Each check is a normal JavaScript test, like typeof, and TypeScript narrows the type to match (the Narrowing lesson covers typeof checks). Step through the checks it takes to read a name from a reply:

Playground: check an unknown reply step by step
Reading a name from an unknown replyPop out in the code editor (opens in a new tab)TypeScript
const data: unknown = JSON.parse('{ "name": "Tea", "price": 3 }'); if (true) {  console.log(data.name.toUpperCase());}
Console outputTEA
What happens
Line 3 provesnothing
TypeScript1 error: line 4
Step 1 of 4

Line 3 checks nothing, so data is still unknown. TypeScript says 'data' is of type 'unknown': you can't read anything from it yet.

TypeScript checks the code for real at every step, and the output is what the code prints. Each step changes only the check on line 3. Reset goes back to step 1.

Step 1 checks nothing, and line 4 can't read anything. Step 2 proves data is an object, but an object with no known properties: Property 'name' does not exist on type 'object'. Step 3 adds "name" in data (the in & instanceof narrowing lesson covers in), so reading data.name is allowed, but the name itself is unknown. Step 4 checks the name is text, and the errors are gone. The code prints TEA at every step: the checks change what TypeScript knows, and the data is the same.

Why does step 2 need data !== null? Because typeof null is "object" in JavaScript, and null is valid JSON. Here is what TypeScript knows after step 3:

What the in check tells TypeScriptPop out in the code editor (opens in a new tab)TypeScript
const data: unknown = JSON.parse('{ "name": "Tea", "price": 3 }'); if (typeof data === "object" && data !== null && "name" in data) {  console.log(data.name);  //          ^?}

The answer on line 5 shows object & Record<"name", unknown>. In plain words: an object, and also (that's the &) something with a name property whose value is unknown. Line 4 prints Tea.

Four cards in a column, joined by arrows. unknown, then the check typeof data === "object" && data !== null gives object, then "name" in data gives an object with a name of type unknown, then typeof data.name === "string" gives an object with a name of type string. Beside each card: can't read anything, can't read name, can't call toUpperCase, and can call toUpperCase.unknowntypeof data === "object" && data !== nullcan't read anythingobject"name" in datacan't read name{ name: unknown }typeof data.name === "string"can't call toUpperCase{ name: string }can call toUpperCase
Each check rules something out, and TypeScript narrows the type to match. Only after the last check is the name known to be text, so data.name.toUpperCase() is allowed.

Check the data before you trust it

Writing those checks at every place that reads a product would be tiring. Put them in one function instead. It takes the reply as unknown and either gives back a real Product or throws an error that says what was wrong:

A function that checks a productPop out in the code editor (opens in a new tab)TypeScript
type Product = { name: string; price: number }; function parseProduct(data: unknown): Product {  if (typeof data !== "object" || data === null) throw new Error("Expected an object");  if (!("name" in data) || typeof data.name !== "string") throw new Error("Expected name to be text");  if (!("price" in data) || typeof data.price !== "number") throw new Error("Expected price to be a number");  return { name: data.name, price: data.price };} const tea = parseProduct(JSON.parse('{ "name": "Tea", "price": 3 }'));//    ^?console.log(`${tea.name} costs $${tea.price}`);

Line 3 says parseProduct takes unknown and gives back a Product. Lines 4 to 6 each throw unless one fact is true: the data is an object, its name is text, and its price is a number. After each throw, TypeScript knows that fact, so line 7 can read both and build a new Product. Line 10 checks a reply, and the answer on line 11 shows tea is a Product. Line 12 prints Tea costs $3.

Only the checks make Product true here. If you forget one, TypeScript notices: line 7 would read a property it can't prove exists. Now send different replies through it:

Playground: what the server sends
parseProduct meets a replyPop out in the code editor (opens in a new tab)TypeScript
type Product = { name: string; price: number }; function parseProduct(data: unknown): Product {  if (typeof data !== "object" || data === null) throw new Error("Expected an object");  if (!("name" in data) || typeof data.name !== "string") throw new Error("Expected name to be text");  if (!("price" in data) || typeof data.price !== "number") throw new Error("Expected price to be a number");  return { name: data.name, price: data.price };} const reply = '{ "name": "Tea", "price": 3 }';try {  const product = parseProduct(JSON.parse(reply));  console.log(`${product.name} costs $${product.price}`);} catch (error) {  console.log(error instanceof Error ? error.message : "Something went wrong");}
Console outputTea costs $3
What happens
The reply{ "name": "Tea", "price": 3 }
parseProductgives back a Product
Try it yourself

Every check passes, so parseProduct gives back a real Product, and the code prints Tea costs $3.

The output is what this code prints for each reply. TypeScript has no errors for any of them: the reply is only known while the page runs. Each button changes only line 10. Reset goes back to A good reply.

A good reply prints Tea costs $3. A price sent as text, a missing name, and null each stop at the check that fails, and the catch on line 14 prints its message instead of a wrong total. An extra field passes, but line 7 copies only name and price. TypeScript has no errors for any reply, because it never sees the reply. That's the point: the checks run while the page runs, where the data is.

(A function that only answers yes or no, value is Product, works too; the Type predicates & assertion functions lesson shows that kind. A function that throws with a message makes a bad reply easier to understand.)

Schema libraries like Zod

A hand-written check has a weak spot: you write the shape twice, once as a type and once as checks, and they can drift apart. Here a new field was added to the type, but not to the check:

A type and a check that drifted apartPop out in the code editor (opens in a new tab)TypeScript
type Product = { name: string; price: number; inStock: boolean }; function isProduct(value: unknown): value is Product {  return (    typeof value === "object" && value !== null &&    "name" in value && typeof value.name === "string" &&    "price" in value && typeof value.price === "number"  );} const data: unknown = JSON.parse('{ "name": "Tea", "price": 3 }');if (isProduct(data)) {  console.log(data.inStock ? "In stock" : "Sold out");}

Line 1 now says every product has inStock. Lines 3 to 9 are a yes-or-no check, value is Product, that still checks only the name and price. The reply on line 11 has no inStock, but the check says yes, so line 13 reads data.inStock, which is undefined, and prints Sold out. TypeScript has no errors: it believes the check.

A schema library fixes this. You describe the shape once, as a schema: an object made by the library that can check data. The library makes the type from the schema, so the two can't drift. A popular one is Zod. A real project installs it with npm install zod and writes import * as z from "zod". This page can't install packages, so the examples import ./mini-zod, a small stand-in written for this lesson with the same few calls. Press Edit & run to see it as a second file.

One schema gives a check and a typePop out in the code editor (opens in a new tab)TypeScript
import * as z from "./mini-zod"; const Product = z.object({ name: z.string(), price: z.number() });type Product = z.infer<typeof Product>;//   ^? const tea = Product.parse(JSON.parse('{ "name": "Tea", "price": 3 }'));console.log(`${tea.name} costs $${tea.price}`); const cake = Product.parse(JSON.parse('{ "name": "Cake", "price": "4" }'));

Line 3 makes the schema: an object whose name is a string and whose price is a number. Line 4 makes the type from it with z.infer, and the answer on line 5 shows the result: { name: string; price: number; }. (Giving the type the same name as the schema is allowed: one is a value, the other a type.) Line 7 checks a good reply with .parse, and line 8 prints Tea costs $3. Line 10 checks the cake, whose price is text, and .parse throws, on purpose here.

A card holding z.object({ name, price }) has two arrows. The upper one leads to a card type Product, labelled z.infer, for TypeScript. The lower one leads to a card checked data, labelled .parse, while the page runs.the schemaz.object({ name, price })type Productz.infer: for TypeScriptchecked data.parse: while the page runs
You write the shape once, as a schema. z.infer turns it into a type for TypeScript, and .parse checks the real data with it while the page runs.

When you'd rather not use try and catch, .safeParse gives back a result instead of throwing:

safeParse says whether the data passedPop out in the code editor (opens in a new tab)TypeScript
import * as z from "./mini-zod"; const Product = z.object({ name: z.string(), price: z.number() }); const result = Product.safeParse(JSON.parse('{ "name": "Cake", "price": "4" }'));if (result.success) {  console.log(result.data.price + 1);} else {  console.log(result.error.message);}

Line 5 checks the cake. The result has success: true with the checked data, or false with an error. Line 6 tests it, so line 7 may read result.data and line 9 may read result.error. The price is text, so line 9 prints the stand-in's message, value.price should be a number. The real Zod words its messages differently, and lists every field that failed. Reading data without testing success first is an error:

Reading data before checking success (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
import * as z from "./mini-zod"; const Product = z.object({ name: z.string(), price: z.number() }); const result = Product.safeParse(JSON.parse('{ "name": "Tea", "price": 3 }'));console.log(result.data.price + 1);

Line 6 reads result.data straight away. TypeScript marks it, on purpose: Property 'data' does not exist on a result that might be the failed kind, which has only error. This kind of type, where one property says which shape you have, is a discriminated union.

The table below compares a hand-written check with a schema library. Read each row as a question, then each answer.

A hand-written check or a schema library
QuestionA type and a hand-written checkA schema library like Zod
How many times do you write the shape?Twice: once as a type and once as checks.Once, as a schema. The type comes from it with z.infer.
What if a new field goes into the type only?The check doesn't know, and data without the field gets through.Fields go into the schema, so a new field is checked and typed at once.
What does a failed check give you?Whatever your code throws.An error that names the fields that failed.
What does it cost?Nothing to install, but more code to write and keep in step.A package to install, which also adds to what your page downloads.

A menu loaded from the server

A real page loads a whole menu, checks the reply's status, checks every item, and shows a message when anything is wrong. Here it is with hand-written checks; with Zod, parseMenu would be z.array(Product).parse:

Load and check a menuPop out in the code editor (opens in a new tab)TypeScript
type Product = { name: string; price: number }; function parseProduct(data: unknown): Product {  if (typeof data !== "object" || data === null) throw new Error("Expected an object");  if (!("name" in data) || typeof data.name !== "string") throw new Error("Expected name to be text");  if (!("price" in data) || typeof data.price !== "number") throw new Error("Expected price to be a number");  return { name: data.name, price: data.price };} function parseMenu(data: unknown): Product[] {  if (!Array.isArray(data)) throw new Error("Expected a list");  return data.map(parseProduct);} async function loadMenu(): Promise<Product[]> {  const response = await fetch("menu.json");  if (!response.ok) throw new Error(`The server answered ${response.status}`);  return parseMenu(await response.json());} loadMenu()  .then((menu) => console.log(menu.map((item) => `${item.name} $${item.price}`).join(", ")))  .catch((error) => console.log(`Menu unavailable: ${error.message}`));

Lines 3 to 8 are parseProduct from before. Lines 10 to 13 check a whole list: line 11 throws unless the data is a list, and line 12 runs parseProduct on every item with map. Line 15 says loadMenu gives back a promise of a product list (the Promises & async functions lesson covers typing promises). Line 17 stops when the server answers with an error status, like 404, because fetch doesn't throw for those. Line 18 checks the JSON before anything else sees it. The code prints Tea $3, Cake $4. If any item failed, line 23 would print Menu unavailable and the reason.

Every line after line 18 works with a checked Product[], with no any left. Now sort some one-line snippets by what TypeScript says about them:

Error or fine?
  • const data = JSON.parse("{}"); data.name.toUpperCase();
  • const data: unknown = JSON.parse("{}"); data.name;
  • const data: unknown = JSON.parse("5"); const count: number = data;
  • const data: unknown = JSON.parse("5"); if (typeof data === "number") data.toFixed(2);
  • const data: unknown = JSON.parse("{}"); if (typeof data === "object") data.name;
  • const data: unknown = JSON.parse("{}"); if (data) data.name;
  • const data: unknown = JSON.parse("5"); const count = data as number;
  • async function load(response: Response) { const name: string = await response.json(); }
Try it yourself
0 of 8 correct

Sort each line by what TypeScript says about it. Each line is checked on its own.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

A successful status doesn't mean the body has the shape you expect. Here the server answers 200, but one menu item uses title instead of name:

An OK status with the wrong shapePop out in the code editor (opens in a new tab)TypeScript
type Product = { name: string; price: number }; async function showPrice() {  const response = await fetch("old-menu.json");  if (response.ok) {    const menu: Product[] = await response.json();    console.log(menu[1].name.toUpperCase());  }} showPrice().catch((error) => console.log(`Crashed: ${error.message}`));

Line 5 checks response.ok, which only says the server answered with a success status. Line 6 says the body is a Product[], with no check. Line 7 reads the second item's name, which doesn't exist, and the code prints Crashed: Cannot read properties of undefined (reading 'toUpperCase'). TypeScript had no errors.

  • "Writing : Product on the reply checks it." No. any goes into any type without a check. The annotation is a promise, like as.
  • "fetchJson<Product>() is safer than : Product." No. A type argument that appears only in the result is the same promise, written in one place.
  • "unknown makes the data safe." No. unknown makes you check. The checks are what make it safe.
  • "response.ok means the body is what I expect." No. It only means a success status, like 200. The body can still have any shape.
  • "Checking the object and its name is enough to call it a Product." No. Check every field the type lists, or a missing price gets through as undefined.
  • "Zod checks the data when TypeScript checks my code." No. TypeScript only gets the type from the schema. The schema checks the data while the page runs, when the data exists.

Practice exercises

Each exercise is TypeScript. Press Edit & run on the starter code to try a fix: the errors update as you type, and the console shows what it prints. Try a guess first, then use the hints.

Exercise 1 · Warm-upRead the type of a JSON reply

What type does order get? Guess first. Then point at order to check, or press Edit & run and add a // ^? line under it.

Starter codePop out in the code editor (opens in a new tab)TypeScript
async function loadOrder() {  const response = await fetch("product.json");  const order = await response.json();}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the line TypeScript marks

    The reply is checked, but not quite enough. Which line will TypeScript mark?

    Starter codePop out in the code editor (opens in a new tab)TypeScript
    const reply = '{ "name": "Tea", "price": 3 }';
    const product: unknown = JSON.parse(reply);
    
    if (typeof product === "object" && product !== null && "price" in product) {
      console.log(product.price * 2);
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeCheck a price before you use it

      TypeScript marks line 3. Add checks so the error goes away without as. What does your fixed code print?

      Starter codePop out in the code editor (opens in a new tab)TypeScript
      const data: unknown = JSON.parse('{ "price": 4 }'); console.log(data.price * 2);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict what the check says

        TypeScript has no errors here. What does the code print?

        Starter codePop out in the code editor (opens in a new tab)TypeScript
        type Product = { name: string; price: number }; function parseProduct(data: unknown): Product {  if (typeof data !== "object" || data === null) throw new Error("Expected an object");  if (!("name" in data) || typeof data.name !== "string") throw new Error("Expected name to be text");  if (!("price" in data) || typeof data.price !== "number") throw new Error("Expected price to be a number");  return { name: data.name, price: data.price };} try {  const product = parseProduct(JSON.parse('{ "name": 7, "price": 3 }'));  console.log(product.name);} catch (error) {  console.log(error instanceof Error ? error.message : "Something went wrong");}

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWrite parseUser

          TypeScript marks line 4: Type 'unknown' is not assignable to type 'User'. Make parseUser check the data, without as. What does your fixed code print?

          Starter codePop out in the code editor (opens in a new tab)TypeScript
          type User = { name: string; age: number }; function parseUser(data: unknown): User {  return data;} const user = parseUser(JSON.parse('{ "name": "Asha", "age": 30 }'));console.log(`${user.name} is ${user.age}`);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeAdd a field to a schema

            Line 6 reads tea.inStock, and TypeScript marks it. Fix the schema, not line 6. What does the fixed code print?

            Starter codePop out in the code editor (opens in a new tab)TypeScript
            import * as z from "./mini-zod"; const Product = z.object({ name: z.string(), price: z.number() }); const tea = Product.parse(JSON.parse('{ "name": "Tea", "price": 3, "inStock": true }'));console.log(tea.inStock ? "In stock" : "Sold out");

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, keep two moments apart: what TypeScript knows before the code runs, and what the data really is when it arrives. The quiz shows its code without the checker, so the error list doesn't give the answers away.

              The Typing fetch & JSON quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What type does TypeScript give the result of await response.json()?

                Choose an answer to see the explanation.

              2. Question 2 of 7Does TypeScript complain about this user from a server, and what prints?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                type User = { name: string; age: number };
                
                const user: User = JSON.parse('{ "name": "Asha", "age": "30" }');
                console.log(user.age + 1);

                Choose an answer to see the explanation.

              3. Question 3 of 7Which error does TypeScript report for this reply typed as unknown?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const data: unknown = JSON.parse('{ "name": "Tea" }');
                console.log(data.name);

                Choose an answer to see the explanation.

              4. Question 4 of 7Why does response.json() give back any instead of a real type?

                Choose an answer to see the explanation.

              5. Question 5 of 7What type does price get inside this check?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const data: unknown = JSON.parse('{ "price": 3 }');
                
                if (typeof data === "object" && data !== null && "price" in data) {
                  const price = data.price;
                }

                Choose an answer to see the explanation.

              6. Question 6 of 7What does this safeParse code print, and does TypeScript complain?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                import * as z from "./mini-zod";
                
                const Product = z.object({ name: z.string(), price: z.number() });
                const result = Product.safeParse(JSON.parse('{ "name": "Tea", "price": 3 }'));
                
                if (result.success) {
                  console.log(result.data.name);
                }

                Choose an answer to see the explanation.

              7. Question 7 of 7What does a schema library like Zod give you that a type alone can't?

                Choose an answer to see the explanation.

              Key takeaways

              • response.json() and JSON.parse give any, because nobody knows what arrives until it arrives.
              • Writing : Product, as Product, or fetchJson<Product>() on the reply checks nothing. It's a promise you make.
              • Give the reply the type unknown. TypeScript then makes you check it before you read anything.
              • Put the checks in one function that takes unknown and gives back a real type, or throws a clear error.
              • A schema library like Zod describes the shape once: .parse checks the data, and z.infer makes the type.

              Remember the one-liner.
              Data from a server is unknown until your code checks it; check once where it arrives, then trust the type.

              Coming next: Promises & async functions. It types the promises you have been awaiting here: Promise<T>, async return types, and Promise.all.

              CompleteFrontend Clear concepts. Working examples.