cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

Discriminated unions

Model loading, success, and error states with a shared tag field, so TypeScript stops you reading data that isn't there. With playgrounds to break.

By the end, you can
  • 01
    Tag each shapeGive every object in a union the same field with its own exact value, and check that field to get one shape.
  • 02
    Switch on the tagHandle each shape in its own case, where TypeScript knows exactly which fields are there.
  • 03
    Model states safelyDescribe loading, success, and error as one type, so states that can't happen can't be written.

Reading data that isn't there yet

A shop page asks a server for its products. While it waits, the page is loading. Then the request either succeeds, and the page has a list of products, or it fails, and the page has an error message. Here is a plain JavaScript function that counts the products:

Counting products the request never sentPop out in the code editor (opens in a new tab)JavaScript
function productCount(state) {
  return `${state.products.length} products`;
}

console.log(productCount({ status: "success", products: ["Tea", "Cake"] }));
console.log(productCount({ status: "error", message: "Network down" }));

Line 1 takes a state object. Line 2 reads state.products.length. Line 5 passes a successful state with two products and prints 2 products. Line 6 passes a failed state, which has a message and no products. So state.products is undefined, reading .length from it throws a TypeError, and the code stops. Nothing warned you before it ran.

In TypeScript you describe the three states as one union type: a type that can be one of several types, written with |, which you read as "or". Each state is an object type with its own fields:

Reading products from any state (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
type ProductsState =  | { status: "loading" }  | { status: "success"; products: string[] }  | { status: "error"; message: string }; function productCount(state: ProductsState) {  return `${state.products.length} products`;} console.log(productCount({ status: "success", products: ["Tea", "Cake"] }));

Lines 1 to 4 make a type called ProductsState. A state is one of three objects. Each one has a status field, and in each, status has one exact value. In a type, "loading" in quotes means "only the text loading", not any string. A type that is one exact value is called a literal type (the Literal types lesson covers them). Line 3 says a successful state also has products, a list of strings, and line 4 says a failed one has a message.

Line 7 reads state.products, and TypeScript marks it on purpose: Property 'products' does not exist on type 'ProductsState'. That means "not every state has products". The message's second line names one that doesn't: { status: "loading"; }. Line 10 still prints 2 products when you run it, but TypeScript has seen the crash coming. Check the status first:

Check the status, then read the productsPop out in the code editor (opens in a new tab)TypeScript
type ProductsState =  | { status: "loading" }  | { status: "success"; products: string[] }  | { status: "error"; message: string }; function productCount(state: ProductsState) {  if (state.status === "success") {    return `${state.products.length} products`;    //        ^?  }  return "No products yet";} console.log(productCount({ status: "success", products: ["Tea", "Cake"] }));console.log(productCount({ status: "error", message: "Network down" }));

Line 7 asks whether state.status is "success". Only one shape has that status, so inside the if, TypeScript keeps only that shape. The query on line 9 shows it: state: { status: "success"; products: string[]; }. Line 8 may read the products, and the error is gone. Line 14 prints 2 products, and line 15 prints No products yet instead of throwing.

Three shapes side by side. Loading has status: "loading" and nothing else. Success has status: "success" and products: string[]. Error has status: "error" and message: string. An arrow from the check state.status === "success" points at the success shape, with the note: products is there to read.ProductsState is one of three shapesstatus: "loading"nothing elsestatus: "success"products: string[]status: "error"message: stringstate.status === "success"products is there to read
Each state carries its own data, and every one has a status. Checking status picks one shape, so on line 8 TypeScript knows products is there.
Real-life analogyLabelled boxes in a stockroom

In a shop's stockroom, every box has a label on the front: shoes, hats, or gloves. You don't open a box and feel around for shoe sizes. You read the label first, and then you know exactly what's inside and what to look for.

In real life: Every box in the stockroom has a label on the front
In TypeScript: Every state has a status field
In real life: A label says one word from a short list: shoes, hats, or gloves
In TypeScript: status is "loading", "success", or "error"
In real life: A box labelled shoes always holds shoes, with their sizes
In TypeScript: A "success" state always holds products
In real life: You read the label before you reach inside
In TypeScript: You check status before you read products
The main idea

A discriminated union is a union of object types that all share one field, called the tag, and each gives it a different exact value, like status: "success". Check the tag, and TypeScript knows which shape you have and which fields are there.

A shared field that names the shape

"Discriminate" means "tell apart". The field that tells the shapes apart is the tag, also called the discriminant. For a check on the tag to pick exactly one shape:

  • every shape in the union has the field, with the same name;
  • each shape gives it an exact value, a literal type;
  • no two shapes use the same value.

Here is a discount at checkout. It takes a percent off, or a fixed amount off:

A discount with a kind fieldPop out in the code editor (opens in a new tab)TypeScript
type Discount =  | { kind: "percent"; percent: number }  | { kind: "fixed"; amount: number }; function priceAfter(discount: Discount, price: number) {  if (discount.kind === "percent") {    return price - (price * discount.percent) / 100;    //                      ^?  }  return price - discount.amount;  //             ^?} console.log(priceAfter({ kind: "percent", percent: 10 }, 20));console.log(priceAfter({ kind: "fixed", amount: 5 }, 20));

Lines 1 to 3 say a Discount is one of two shapes. The tag is kind. A percent discount has a percent, and a fixed discount has an amount. Line 6 checks the kind. Inside the if, the query on line 8 shows discount is the percent shape, so line 7 may read discount.percent.

Line 7 also returns, so the code after the if only runs for the other shape. The query on line 11 shows discount is the fixed shape there, and line 10 reads discount.amount. Line 14 prints 18, which is 10% off 20, and line 15 prints 15, which is 5 off 20.

The tag can have any name. kind, type, and status are common. Its values can be text, numbers, or true and false. What matters is that each value is exact. Watch what happens when the tag is any string:

A kind that can be any string (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
type Discount =  | { kind: string; percent: number }  | { kind: string; amount: number }; function priceAfter(discount: Discount, price: number) {  if (discount.kind === "percent") {    return price - (price * discount.percent) / 100;  }  return price - discount.amount;} console.log(priceAfter({ kind: "percent", percent: 10 }, 20));

The only change is on lines 2 and 3: kind is a string in both shapes. Now "percent" fits both, so line 6 can't pick one. TypeScript marks line 7, on purpose: Property 'percent' does not exist on type 'Discount', because a fixed discount could get inside the if. Line 9 gets the same error for amount. The code runs and prints 18, but TypeScript can no longer check it for you.

Now change the check yourself. Type a different kind for line 6, and watch the list under the code as you type:

Playground: type the kind you check
A discount checked by its kindPop out in the code editor (opens in a new tab)TypeScript
type Discount =  | { kind: "percent"; percent: number }  | { kind: "fixed"; amount: number }; function priceAfter(discount: Discount, price: number) {  if (discount.kind === "percent") {    return price - (price * discount.percent) / 100;  }  return price - discount.amount;} console.log(priceAfter({ kind: "percent", percent: 10 }, 20));
Line 6 checks for"percent"
What happens
TypeScriptNo errors
When it runsprints 18
Try it yourself

Line 6 checks for a percent discount. Inside the if, TypeScript knows discount has percent, and after it, only the fixed discount with its amount is left. No errors, and the code prints 18: 10% off 20.

The code is checked by the real TypeScript compiler each time you type. Only line 6 changes. Reset puts "percent" back.

With "fixed", the two branches swap shapes, and the reads on lines 7 and 9 no longer match: two errors. With the typo "percnt", TypeScript marks line 6 itself, because no discount can have that kind. That's a bug caught on the check, before anyone reads the wrong field. Put "percent" back and the errors go away.

The in & instanceof lesson told shapes apart with in, which asks whether an object has a field. That works too, but a tag is clearer. The table compares the two. Read each row as a question, then each check's answer.

Telling shapes apart with in, and with a tag
QuestionCheck a field with inCheck the tag
What do you write?"products" in statestate.status === "success"
What happens when you misspell it?The check itself gets no error. The confusion shows up on a later line.The check gets an error right away: the two types have no overlap.
Can it tell apart two shapes with the same fields?No. If both shapes have a message, in matches both.Yes. Each shape has its own tag value, even when the other fields match.
Can one switch handle every shape?No. Each in check needs its own if.Yes. Each case names one tag value.

switch on the tag

With three or more shapes, a chain of if checks gets long. A switch on the tag reads better: it compares one value with each case and runs the one that matches. TypeScript narrows inside every case. Here is a shopping cart that describes what the shopper just did:

A switch on the action's typePop out in the code editor (opens in a new tab)TypeScript
type CartAction =  | { type: "add"; name: string; price: number }  | { type: "remove"; name: string }  | { type: "clear" }; function describe(action: CartAction) {  switch (action.type) {    case "add":      return `Added ${action.name} for $${action.price}`;    case "remove":      return `Removed ${action.name}`;      //                ^?    case "clear":      return "Emptied the cart";  }} console.log(describe({ type: "add", name: "Tea", price: 3 }));console.log(describe({ type: "remove", name: "Tea" }));console.log(describe({ type: "clear" }));

Lines 1 to 4 describe three cart actions. Adding needs a name and a price, removing needs only a name, and clearing needs nothing else. The tag is type. Line 7 switches on action.type.

  • In case "add":, line 9 reads the name and the price.
  • In case "remove":, line 11 reads the name. The query on line 12 shows action: { type: "remove"; name: string; }: no price here.
  • In case "clear":, line 14 needs nothing from the action.

Lines 18 to 20 print Added Tea for $3, Removed Tea, and Emptied the cart.

A card that says switch (action.type) has three arrows. One goes to case "add", where the action has name and price. One goes to case "remove", where it has name. One goes to case "clear", where it has no other fields.switch (action.type)the casewhat action hascase "add"name, pricecase "remove"namecase "clear"no other fields
The switch reads action.type once and jumps to the matching case. Inside each case, TypeScript keeps only the action with that tag: case "add" can read a name and a price, and case "clear" has nothing else to read.

Two cases can share one body. TypeScript then keeps both shapes:

Two cases that share one bodyPop out in the code editor (opens in a new tab)TypeScript
type CartAction =  | { type: "add"; name: string; price: number }  | { type: "remove"; name: string }  | { type: "clear" }; function itemName(action: CartAction) {  switch (action.type) {    case "add":    case "remove":      return action.name;      //     ^?    case "clear":      return "every item";  }} console.log(itemName({ type: "remove", name: "Cake" }));console.log(itemName({ type: "clear" }));

Lines 8 and 9 list "add" and "remove" with nothing between them, so both run line 10. The query on line 11 shows action is the add shape or the remove shape. Both have a name, so line 10 may read it. Line 17 prints Cake and line 18 prints every item.

Now send the function different actions. Each button changes the object on line 17:

Playground: send an action
A cart action described by its typePop out in the code editor (opens in a new tab)TypeScript
type CartAction =  | { type: "add"; name: string; price: number }  | { type: "remove"; name: string }  | { type: "clear" }; function describe(action: CartAction) {  switch (action.type) {    case "add":      return `Added ${action.name} for $${action.price}`;    case "remove":      return `Removed ${action.name}`;    case "clear":      return "Emptied the cart";  }} console.log(describe({ type: "add", name: "Tea", price: 3 }));
Console outputAdded Tea for $3
What happens
The case that runscase "add"
TypeScriptNo errors
Try it yourself

The tag is "add", so the switch runs lines 8 and 9. In that case TypeScript knows the action has a name and a price. It prints Added Tea for $3.

TypeScript checks the code for real, and the output is what this code prints. Each button changes only line 17, and the highlighted lines are the ones that run. Reset goes back to Add tea.

The tag decides two things. While the page runs, it decides which case runs: the highlighted lines. Before it runs, it decides what TypeScript expects: an add action must have a price, so Add with no price gets an error and prints $undefined. And A refund isn't one of the three tags, so TypeScript marks it before it can fall through every case and print undefined.

Loading, success, and error as one type

Almost every page that loads data goes through the same moments: waiting for the reply, showing the data, or showing what went wrong. ProductsState describes them, and the function that loads the products gives back one of them:

Loading products gives back one stateTypeScript
type ProductsState =  | { status: "loading" }  | { status: "success"; products: string[] }  | { status: "error"; message: string }; async function loadProducts(): Promise<ProductsState> {  try {    const response = await fetch("/api/products");    const products: string[] = await response.json();    return { status: "success", products };  } catch (error) {    return { status: "error", message: String(error) };  }}

This code needs a real server at /api/products, so it has no Run button here. Line 6 says loadProducts gives back a Promise<ProductsState>: a promise that later hands over a ProductsState (async and await covers promises). Line 8 asks the server for the products, and line 9 reads the list from its reply. Line 10 returns a success state with that list. If anything goes wrong, line 12 returns an error state with a message. Each return builds one shape, and TypeScript checks it against the shape its tag names. The page starts as { status: "loading" } and shows whatever comes back.

Now put the page in each state. Lines 6 to 15 turn a state into what the page shows. Line 17 stores a state, and line 18 prints what the page shows for it. Each button changes line 17:

Playground: put the page in a state
A product list in one of three statesPop out in the code editor (opens in a new tab)TypeScript
type ProductsState =  | { status: "loading" }  | { status: "success"; products: string[] }  | { status: "error"; message: string }; function render(state: ProductsState) {  switch (state.status) {    case "loading":      return "Loading products";    case "success":      return state.products.join(", ");    case "error":      return `Something went wrong: ${state.message}`;  }} const state: ProductsState = { status: "loading" };console.log(render(state));
What the page showsLoading products
What happens
statusloading
TypeScriptNo errors
Try it yourself

The request hasn't answered yet. A loading state has nothing but its tag, and the page shows Loading products.

TypeScript checks the code for real, and the page shows what this code prints. Each button changes only line 17. Reset goes back to Loading.

Loading, Loaded, and Failed each run their own case, and each case reads only the data its state has. Loaded, but no list says it succeeded without saying what it found. TypeScript checks it against the success shape and reports Property 'products' is missing. Without that check, line 11 would throw a TypeError on the page.

States that can't happen

Before discriminated unions, many pages kept one object with a loading flag and optional fields. In a type, a ? after a field's name makes it optional: the object may leave it out. (The Object types lesson covers this.) Here is the same product list written that way:

A state with optional fields (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
type ProductsState = {  loading: boolean;  products?: string[];  error?: string;}; function render(state: ProductsState) {  if (state.loading) return "Loading products";  if (state.error) return `Something went wrong: ${state.error}`;  return state.products.join(", ");} console.log(render({ loading: true, products: ["Tea", "Cake"], error: "Network down" }));console.log(render({ loading: false }));

Lines 1 to 5 describe a state with a loading flag and two optional fields. Line 8 returns early while loading, and line 9 returns early when there's an error. You know that leaves a successful state with products. TypeScript doesn't: nothing in the type ties "not loading and no error" to "has products". So it marks line 10, on purpose: 'state.products' is possibly 'undefined'.

It's right to worry. Line 13 passes a state that is loading, has two products, and failed. TypeScript accepts it, and it prints Loading products. Line 14 passes a state that isn't loading, has no error, and has no products. TypeScript accepts that too, and line 10 throws a TypeError.

Left, optional fields: one object holds loading: true, products: ["Tea", "Cake"], and error: "Network down" at once, marked all three at once, no error. Right, a union of states: three separate shapes, status loading, status success with products, and status error with a message, marked one state at a time.Optional fieldsstateloading: trueproducts: ["Tea", "Cake"]error: "Network down"all three at once: no errorA union of statesstatus: "loading"orstatus: "success", productsorstatus: "error", messageone state at a time
On the left, one object says it is loading, has two products, and failed, all at once, and the type allows it. On the right, each object is exactly one of three states, so that object can't be written.

Count what the loose type allows. loading is true or false, and products and error are each there or missing: 2 × 2 × 2 = 8 combinations. Only 3 of them make sense. The union allows exactly those 3, so a state that can't happen can't be written:

The same mix in a union (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
type ProductsState =  | { status: "loading" }  | { status: "success"; products: string[] }  | { status: "error"; message: string }; const state: ProductsState = {  status: "loading",  products: ["Tea", "Cake"],  message: "Network down",};

Line 7 says the state is loading. A loading state has no other fields, so TypeScript marks line 8, on purpose: Object literal may only specify known properties, and 'products' does not exist in type '{ status: "loading"; }'. An object literal is an object written out in { }, so this means "this object has a field its shape doesn't list". TypeScript reports the first extra field it finds; remove products and line 9 gets the same error for message.

The table compares the two designs. Read each row as a question, then each design's answer.

Optional fields and a union of states, side by side
QuestionOptional fieldsA union of states
How many combinations does the type allow?Eight: loading is true or false, and products and error are each there or missing.Three: loading, success, or error.
Is loading, with products, with an error, allowed?Yes, with no error at all.No. A loading state has no products, so TypeScript marks it.
After you rule out loading and errors, can you read the products?No. TypeScript still says 'state.products' is possibly 'undefined'.Yes. Inside case "success":, products is always there.
What happens when a state leaves out its data?Nothing. Every field is optional, so nothing is missing.An error: Property 'products' is missing.

A profile page and its API reply

A profile page asks the server for the signed-in user. Many APIs answer with an ok field: true with the data, or false with an error. That's a tag too, with true and false as its two exact values:

A reply tagged with ok: true or ok: falsePop out in the code editor (opens in a new tab)TypeScript
type User = { name: string; email: string };type UserReply =  | { ok: true; user: User }  | { ok: false; error: string }; function greeting(reply: UserReply) {  if (reply.ok) {    return `Welcome back, ${reply.user.name}`;  }  return `Could not load your profile: ${reply.error}`;  //                                     ^?} console.log(greeting({ ok: true, user: { name: "Asha", email: "asha@example.com" } }));console.log(greeting({ ok: false, error: "Signed out" }));

Line 1 describes a User. Lines 2 to 4 say a reply is ok: true with a user, or ok: false with an error. Line 7 checks reply.ok. Inside, only the ok: true shape is left, so line 8 may read reply.user.name. After the if, the query on line 11 shows reply: { ok: false; error: string; }, and line 10 reads the error. Line 14 prints Welcome back, Asha, and line 15 prints Could not load your profile: Signed out.

Here is the function that builds those replies from a real request:

Turning a fetch into a tagged replyTypeScript
type User = { name: string; email: string };type UserReply =  | { ok: true; user: User }  | { ok: false; error: string }; async function loadUser(id: number): Promise<UserReply> {  const response = await fetch(`/api/users/${id}`);  if (!response.ok) {    return { ok: false, error: `The server said ${response.status}` };  }  const user: User = await response.json();  return { ok: true, user };}

This code needs a real server, so it has no Run button here. Line 7 asks for the user with fetch. Line 8 checks the browser's own response.ok, which is false when the server answers with an error code like 404. Then line 9 returns a failed reply. Otherwise line 11 reads the user from the reply's JSON, and line 12 returns a successful one. Every page that calls loadUser has to check ok before it can reach the user.

Error or fine?
  • reply.user.name;
  • if (reply.ok) reply.user.name;
  • if (!reply.ok) reply.error;
  • if (reply.ok) reply.error;
  • if (reply.ok === false) reply.error;
  • const signedOut: UserReply = { ok: false, error: "Signed out" };
  • const broken: UserReply = { ok: false };
  • const mixed: UserReply = { ok: true, error: "Signed out" };
Try it yourself
0 of 8 correct

Sort each line by what TypeScript says about it. Each line is checked on its own, after the User and UserReply types above, with reply holding a UserReply.

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

Common mix-ups

A tag only helps when the data really has it. Here a server reply claims to be a success:

A reply that says success and has no productsPop out in the code editor (opens in a new tab)TypeScript
type ProductsState =  | { status: "loading" }  | { status: "success"; products: string[] }  | { status: "error"; message: string }; const state: ProductsState = JSON.parse('{ "status": "success" }');if (state.status === "success") {  console.log(state.products.length);}

Line 6 reads a state from a server reply with JSON.parse. JSON.parse gives back the type any, which TypeScript never checks, so it trusts the ProductsState type you wrote. Line 7 checks the tag, and TypeScript reports nothing. But the reply has no products, so line 8 throws a TypeError when it runs. The types are removed before the code runs, so nothing checks that the tag matches the fields.

  • "The tag has to be called kind." No. Any name works, as long as every shape has it. type, status, and ok are common too.
  • "Any shared field works as a tag." No. Each shape has to give it an exact value. With kind: string in every shape, checking it can't pick one.
  • "Optional fields are just as safe." No. They allow states that can't happen, like loading with an error, and they make you check fields TypeScript can't connect.
  • "TypeScript checks the tag while the page runs." No. It checks your code before it runs. A reply that says "success" and has no products gets through.
  • "Discriminated unions need special syntax." No. They're ordinary unions of object types. The shared field with exact values is all it takes.
  • "I can check state.products to find a successful state." No. Until the tag is checked, you can read only fields every shape has, like status. if (state.products) is an error of its own, because a loading state has no products to read.

Practice exercises

Each exercise is TypeScript. When you fix or write code, press Edit & run on the starter code: 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 inside a branch

What type does discount have on line 7? Guess first. Then point at discount 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
type Discount =  | { kind: "percent"; percent: number }  | { kind: "fixed"; amount: number }; function label(discount: Discount) {  if (discount.kind === "fixed") {    return `$${discount.amount} off`;  }  return `${discount.percent}% off`;}

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

    Exercise 2 · Warm-upFind the line TypeScript marks

    One case reads a field its state doesn't have. Which line will TypeScript mark?

    Starter codePop out in the code editor (opens in a new tab)TypeScript
    type ProductsState =
      | { status: "loading" }
      | { status: "success"; products: string[] }
      | { status: "error"; message: string };
    
    function render(state: ProductsState) {
      switch (state.status) {
        case "loading":
          return "Loading products";
        case "success":
          return state.message;
        case "error":
          return `Something went wrong: ${state.message}`;
      }
    }

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

      Exercise 3 · PracticeGive two toasts a tag

      A toast is a small message that pops up on a page, like "Saved". Some toasts have an Undo button. Give both toast types a tag, and make buttonText return the undo label for undo toasts and "Close" for the others. What does it print for the info toast?

      Starter codePop out in the code editor (opens in a new tab)TypeScript
      type InfoToast = { text: string };type UndoToast = { text: string; undoLabel: string };type Toast = InfoToast | UndoToast; function buttonText(toast: Toast) {  return toast.undoLabel;} console.log(buttonText({ text: "Saved" }));

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

        Exercise 4 · PracticeTurn optional fields into states

        A Save button keeps its state with a flag and optional fields, and TypeScript marks line 10. Rewrite SaveState as a discriminated union, and handle each state. What does your fixed code print?

        Starter codePop out in the code editor (opens in a new tab)TypeScript
        type SaveState = {  saving: boolean;  savedAt?: string;  error?: string;}; function saveLabel(state: SaveState) {  if (state.saving) return "Saving";  if (state.error) return `Not saved: ${state.error}`;  return `Saved at ${state.savedAt.slice(0, 5)}`;} console.log(saveLabel({ saving: false, savedAt: "14:30:05" }));

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

          Exercise 5 · ChallengeAdd a state for a search box

          The product list is now a search box. Before the visitor types, nothing has been asked yet. Add an "idle" state that shows Type to search. How many states does ProductsState have after your change?

          Starter codePop out in the code editor (opens in a new tab)TypeScript
          type ProductsState =  | { status: "loading" }  | { status: "success"; products: string[] }  | { status: "error"; message: string }; function render(state: ProductsState) {  switch (state.status) {    case "loading":      return "Loading products";    case "success":      return state.products.join(", ");    case "error":      return `Something went wrong: ${state.message}`;  }} console.log(render({ status: "idle" }));

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

            Exercise 6 · ChallengeModel a newsletter form

            A newsletter form is being edited, sending, sent, or failed. The function is written; the type is missing. Write SignupState so the errors go away. What does the code print?

            Starter codePop out in the code editor (opens in a new tab)TypeScript
            // Write SignupState here: editing, sending, sent (with the email), or failed (with a message). function statusText(state: SignupState) {  switch (state.status) {    case "editing":      return "Enter your email";    case "sending":      return "Sending";    case "sent":      return `Check ${state.email} for a link`;    case "failed":      return `Try again: ${state.message}`;  }} console.log(statusText({ status: "sent", email: "asha@example.com" }));

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

              Check your understanding

              For each question, ask which shape the code has at that line, and what that shape's fields are. The quiz shows its code without the checker, so the error list doesn't give the answers away.

              The Discriminated unions quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What makes a union of object types a discriminated union?

                Choose an answer to see the explanation.

              2. Question 2 of 7Which line does TypeScript flag in this cart action code?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                type CartAction =
                  | { type: "add"; name: string; price: number }
                  | { type: "remove"; name: string };
                
                function priceOf(action: CartAction) {
                  if (action.type === "add") {
                    return action.price;
                  }
                  return action.price;
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7What type does state have on line 9, inside case "error"?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                type ProductsState =
                  | { status: "loading" }
                  | { status: "success"; products: string[] }
                  | { status: "error"; message: string };
                
                function render(state: ProductsState) {
                  switch (state.status) {
                    case "error":
                      return state.message;
                    default:
                      return "All good";
                  }
                }

                Choose an answer to see the explanation.

              4. Question 4 of 7Does TypeScript accept this state, which is loading and failed at once?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                type ProductsState = {
                  loading: boolean;
                  products?: string[];
                  error?: string;
                };
                
                const state: ProductsState = { loading: true, error: "Network down" };

                Choose an answer to see the explanation.

              5. Question 5 of 7Why does TypeScript mark line 7 in this notice code?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                type Notice =
                  | { kind: string; text: string }
                  | { kind: string; text: string; undoLabel: string };
                
                function buttonText(notice: Notice) {
                  if (notice.kind === "undo") {
                    return notice.undoLabel;
                  }
                  return "Close";
                }

                Choose an answer to see the explanation.

              6. Question 6 of 7What happens when this server reply code runs?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                type UserReply =
                  | { ok: true; name: string }
                  | { ok: false; error: string };
                
                const reply: UserReply = JSON.parse('{ "ok": true }');
                if (reply.ok) {
                  console.log(reply.name.toUpperCase());
                }

                Choose an answer to see the explanation.

              7. Question 7 of 7Which pair of fields can work as a tag?

                Choose an answer to see the explanation.

              Key takeaways

              • A discriminated union is a union of object types that share one field, the tag, with a different exact value in each.
              • Checking the tag with ===, !==, or a truthiness check narrows the whole object to one shape.
              • A switch on the tag gives each shape its own case, and two cases can share one body.
              • The tag needs exact values: text, numbers, or true and false. A tag typed as string can't pick a shape.
              • Describe loading, success, and error as one union, so each state carries exactly its own data.
              • Optional fields allow states that can't happen; a union rules them out before the code runs.
              • Data from a server isn't checked while the page runs, so a wrong tag can still get through.

              Remember the one-liner.
              Give every shape the same tag with its own value, check the tag first, and TypeScript hands you exactly the fields that are there.

              Coming next: Exhaustiveness checking. It makes TypeScript tell you when a switch forgets one of the union's shapes, like a new state you just added.

              CompleteFrontend Clear concepts. Working examples.