cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

Control flow analysis

Follow how TypeScript tracks a variable's type through checks, returns, assignments, and callbacks, and learn why narrowing is kept or lost.

By the end, you can
  • 01
    Trace a variable through the flowSay what type a variable has on any line by following the checks, assignments, and joins on the paths that reach it.
  • 02
    Predict when narrowing is lostExplain why callbacks, assignments, and saved checks keep or lose a narrowing, and why calls and awaits don't reset it.
  • 03
    Fix flow errorsFix possibly-undefined errors inside callbacks, variables used before they're assigned, and code that can never run.

A variable's type, line by line

When you check a value, TypeScript gives it a more precise type after the check. That is called narrowing, and Narrowing showed the checks that do it. This lesson shows how TypeScript keeps track: how it knows, on any line, which checks and assignments came before it. Here a shop shows a discount. The discount may be a saved coupon object, text the shopper typed, or null for none:

One coupon, three typesPop out in the code editor (opens in a new tab)TypeScript
type Coupon = { code: string; percent: number }; function discountText(coupon: Coupon | string | null) {  if (coupon === null) {    return "No coupon";  }  if (typeof coupon === "string") {//           ^?    coupon = { code: coupon.toUpperCase(), percent: 10 };//                   ^?  }  return `${coupon.code}: ${coupon.percent}% off`;//          ^?} console.log(discountText(null));console.log(discountText("tea10"));console.log(discountText({ code: "CAKE5", percent: 5 }));

Line 1 describes a Coupon: a code and a percent. Line 3 says coupon may be a Coupon, a string, or null. That is its declared type, the type written where it's declared. Line 4 handles null, and line 5 returns.

  1. The query on line 8 points at coupon on line 7 and shows string | Coupon: null is gone.
  2. Inside line 7's if, the query on line 10 shows string, so line 9 can call toUpperCase(). Line 9 then stores a new Coupon object in coupon.
  3. The query on line 13 shows Coupon on line 12: every path that reaches line 12 brings a coupon object, so coupon.code is fine.

Lines 16 to 18 print No coupon, TEA10: 10% off, and CAKE5: 5% off.

One parameter has three different types on three lines, and TypeScript never ran the code to find them. It read the paths: the routes the code can take from the top of the function to each line. Working out types along those paths is called control flow analysis. "Control flow" is the order in which code can run. Here are the paths through discountText:

Paths through discountText. At the top, line 3: string | Coupon | null. Line 4 true leads to null, and line 5 returns. Line 4 false leads to string | Coupon. From there, line 7 true leads to string, and line 8 stores a Coupon. Line 7 false and the line 8 path both lead to Coupon on line 10.line 3string | Coupon | nullline 4 truenullline 5 returnsthis path endsline 4 falsestring | Couponline 7 truestringline 7 falseline 8 stores a CouponCouponline 10: the paths join
Each card is the type of coupon at one point. Line 4 splits the flow: null takes the true path and returns on line 5. Line 7 splits it again. Both paths into line 10 bring a Coupon, so the join is Coupon.
Real-life analogyWalking back along a trail

You stand at a spot in a park and want to know which animals could be here. You walk back along every trail that leads to your spot. At each gate, you read its sign: "dogs only past here". A trail that ends at a cliff leads nowhere, so you ignore it. Where two trails join, an animal could have come along either one, so you count both. Back at the park's entrance, you know exactly who can reach your spot.

In real life: A trail with forks and gates
In TypeScript: A function with ifs and returns
In real life: A gate that only lets dogs through
In TypeScript: A check like coupon === null
In real life: A path that ends at a cliff
In TypeScript: A return or a throw
In real life: Two trails that join
In TypeScript: The end of an if, where paths meet
In real life: Walking back from where you stand
In TypeScript: The checker working out a type for one line
The main idea

Control flow analysis works out a variable's type on each line from the paths that reach that line. Every path starts at the declared type. Each check narrows it, each assignment replaces it, paths that return or throw drop out, and where paths meet, their types are joined into a union.

Flow nodes: a map of every path

The compiler does this in two parts:

  • First, the binder, which Inside the compiler showed giving every name a symbol, walks your code once. On the same walk it records every path as a chain of small markers called flow nodes: where a function starts, the true and the false path of each check, each assignment, each place where paths meet, and where a path ends.
  • Then the checker, the part that works out types and reports errors, uses them. When it needs coupon's type on a line, it starts at that line and walks back along the flow nodes to the declaration. On the way it applies each check and assignment it passes, and where paths meet, it follows each one and joins the answers.

The checker only does this for the lines that use a variable, and it keeps some answers it has already worked out, so it doesn't walk the same stretch of paths twice. The table below lists the kinds of flow node. Read each row as: this code, the step it adds to the flow, and what that step does to the type.

What each kind of code adds to the flow
The codeThe step it addsWhat happens to the type
A parameter or a declared variable, like coupon: Coupon | string | nullThe start of every path.The declared type is what the variable has before any check.
A check in an if, a while, a ? :, an &&, an ||, or a switch caseTwo paths: one for true and one for false.Each path keeps only the members that pass, or that fail, the check.
An assignment, like coupon = { code: …, percent: 10 }An assignment step.The variable takes the type of what was stored. Any earlier narrowing is dropped.
The end of an if, a switch, or a loopA join, where paths meet.The types from every path that arrives are joined with a union.
return, throw, or a call to a function declared to return neverThe end of a path.Nothing after it is reached from this path, so it adds nothing to later joins.

Here is discountText again, without the queries. Press each step to move through the flow nodes in the order the code runs, and watch coupon's type:

Playground: walk the paths of discountText
discountText, one flow node at a timePop out in the code editor (opens in a new tab)TypeScript
type Coupon = { code: string; percent: number }; function discountText(coupon: Coupon | string | null) {  if (coupon === null) {    return "No coupon";  }  if (typeof coupon === "string") {    coupon = { code: coupon.toUpperCase(), percent: 10 };  }  return `${coupon.code}: ${coupon.percent}% off`;}
Flow nodeStart: the declared type
What the checker knows
Line3
coupon herestring | Coupon | null
Step 1 of 7

Line 3 declares coupon as Coupon | string | null. Every path through the function starts from this declared type. The checker writes it as string | Coupon | null: the order inside a union doesn't matter.

The types are the ones the real TypeScript compiler reports on each line. This follows what the checker knows; it doesn't run the code. Reset goes back to step 1.

Two steps matter most. Step 6: an assignment replaces the narrowed type with the type of what was stored, so the string from line 7 is gone. Step 7: line 10 is a join, a place where paths meet. One path brings the Coupon stored on line 8, the other brings the Coupon left on line 7's false path, and the union of the two is Coupon. The path through line 5 ended, so its null never reaches line 10.

Joins and impossible branches

Every check from Narrowing, in and instanceof, and Type guards is the same kind of step for the checker: a split into a true path and a false path. &&, ||, ??, ? :, and each case of a switch split the flow the same way. This section looks at the other end: what happens where paths meet.

Two paths store two typesPop out in the code editor (opens in a new tab)TypeScript
function shippingFee(express: boolean) {  let fee: number | string;  if (express) {    fee = 10;  } else {    fee = "free";  }  return fee;//       ^?} console.log(shippingFee(true), shippingFee(false));

Line 2 declares fee as number | string with no value yet. On the true path, line 4 stores 10, so there fee is a number. On the false path, line 6 stores "free", a string. Line 8 is where both paths meet, and the query on line 9 shows let fee: string | number: the union of what each path brought. Line 12 prints 10 free.

How inference works showed let total;, with no type, becoming a number after its first assignment. That is this same machinery. A let with no type and no value has no declared type to start from, so the assignments on each path decide everything:

A label with no type of its ownPop out in the code editor (opens in a new tab)TypeScript
function badgeText(count: number) {  let label;  if (count > 9) {    label = "9+";  } else {    label = count;  }  return label;//       ^?} console.log(badgeText(12), badgeText(3));

Line 2 declares label with nothing. Line 4 stores "9+" on one path and line 6 stores a number on the other. At line 8 the paths meet, and the query on line 9 shows let label: string | number. Line 12 prints 9+ 3. An empty array learns its type the same way: each push on it is a step in the flow too.

Checks work the other way: each one takes members out of a union. When every member has been taken out, no value can reach the next line:

Every status handled before the last linePop out in the code editor (opens in a new tab)TypeScript
type Status = "loading" | "ready" | "failed"; function statusText(status: Status) {  if (status === "loading") return "Loading";  if (status === "ready") return "Ready";  if (status === "failed") return "Try again";  return status;//       ^?} console.log(statusText("failed"));

Line 1 lists three statuses. Lines 4, 5, and 6 each handle one and return. No path is left for line 7, and the query on line 8 shows status: never. never is the type with no values: the checker's way of saying this line can't be reached with any status. Line 11 prints Try again. If you added a fourth status to line 1, it would be the only member to reach line 7. Exhaustiveness checking uses exactly this to catch a forgotten case.

Why narrowing resets in callbacks

A callback is a function you hand to other code to call later, like the function you pass to setTimeout (see Timers). It can read the variables of the function around it, because it is a closure. For the checker, that's a problem: the callback runs later, and by then the variable may hold something else.

Here a search waits before it runs. Line 2 returns when there's no query. Line 3 makes a callback that reads query on line 4. Pick what line 6 does, after the callback is made:

Playground: what comes after the callback?
A search that runs laterPop out in the code editor (opens in a new tab)TypeScript
function scheduleSearch(query: string | undefined) {  if (query === undefined) return;  setTimeout(() => {    console.log(`Searching for ${query.toUpperCase()}`);  }, 0);  console.log("Search scheduled");} scheduleSearch("tea");
Line 6console.log("Search scheduled");
What happens
TypeScriptNo errors
When it runsprints Search scheduled, then Searching for TEA
Try it yourself

No assignment to query comes after line 3 makes the callback, so line 2's narrowing is kept inside it. Since TypeScript 5.4, the checker looks for the last assignment to a let or a parameter, and here there's none after line 3.

TypeScript checks each version for real. The buttons change only line 6. Reset goes back to Log a message.

The rule TypeScript 5.4 added: inside a callback, a let variable or a parameter keeps its narrowing only if no assignment to it comes after the place where the callback is made, and none is inside another function. It looks at where the assignments are, not at what they store. A const can never be assigned again, so it always keeps its narrowing.

Four steps from top to bottom. Line 2: check query, so query is a string. Line 3: make the callback, which is stored for later. Line 6: query = undefined, so query changes. Later: the callback runs and reads undefined.line 2: check queryquery is a stringline 3: make the callbackkept for laterline 6: query = undefinedquery changeslater: the callback runsreads undefined
The callback is made on line 3 but runs later, after line 6 has stored undefined. Because an assignment comes after the callback is made, TypeScript can't use line 2's check inside it.

The rule covers arrow functions and function expressions. A function declaration inside the function never keeps the narrowing: it is hoisted, so it could be called before the check even ran.

Properties are stricter still. Their narrowing is never kept inside a callback:

A property in a callback (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
type Search = { query?: string }; function scheduleSearch(search: Search) {  if (search.query === undefined) return;  setTimeout(() => console.log(search.query.length), 0);   const query = search.query;  setTimeout(() => console.log(query.length), 0);}

Line 4 returns when search.query is undefined. Line 5 reads it inside a callback, and TypeScript marks it on purpose: 'search.query' is possibly 'undefined'. In plain words: before the timer fires, any code that holds the search object could clear its query. Line 7 copies the value into a const after the check, and the callback on line 8 reads the copy, with no error. A const copy is the usual fix.

Properties, calls, and await

Outside callbacks, a check narrows a property the way it narrows a variable. The checker tracks cart.coupon as one thing, a reference: a variable followed by property names. Its narrowing lasts until something changes it. What counts as a change? Line 5 checks cart.coupon and line 7 uses it. Pick what happens on line 6:

Playground: what resets cart.coupon?
A coupon labelPop out in the code editor (opens in a new tab)TypeScript
type Cart = { items: number; coupon?: string };const clearCoupon = (cart: Cart) => { cart.coupon = undefined; }; function couponLabel(cart: Cart) {  if (cart.coupon !== undefined) {    console.log("Applying coupon");    return cart.coupon.toUpperCase();  }  return "No coupon";} console.log(couponLabel({ items: 2, coupon: "tea10" }));
Line 6console.log("Applying coupon");
What happens
TypeScriptNo errors
When it runsprints Applying coupon, then TEA10
Try it yourself

Nothing between line 5's check and line 7 touches cart.coupon, so line 7 reads a string. The code prints Applying coupon, then TEA10.

TypeScript checks each version for real, and the result is what the code does when it runs. The buttons change only line 6. Reset goes back to Log a message.

Assignments reset the narrowing: one to cart.coupon, or one to cart, which resets everything that starts with cart.. A function call doesn't, even one that clears the coupon. The checker doesn't look inside the functions you call; it assumes a call changes nothing. Resetting after every call would be safer, but you would have to check again after each console.log. TypeScript chose the useful rule over the strict one here, on purpose.

An await works the same way. While a function waits, other code can run (see async and await), but the checker keeps the narrowing:

Narrowing kept across an awaitPop out in the code editor (opens in a new tab)TypeScript
type Cart = { items: number; coupon?: string }; async function applyCoupon(cart: Cart, save: () => Promise<void>) {  if (cart.coupon === undefined) return "No coupon";  await save();  return cart.coupon.toUpperCase();} applyCoupon({ items: 2, coupon: "tea10" }, async () => {}).then(console.log);

Line 4 returns when there's no coupon. Line 5 waits for save(). Line 6 still sees a string, with no error, and line 9 prints TEA10. If other code might change the coupon while you wait, copy it into a const before the await, as with callbacks.

Checks saved for later

A long check reads better with a name. Since TypeScript 4.4, a check saved in a const still narrows. TypeScript calls this an aliased condition: the const is another name, an alias, for the check.

A check saved in a constPop out in the code editor (opens in a new tab)TypeScript
function priceLabel(name: string | undefined, price: number | undefined) {  const ready = name !== undefined && price !== undefined;  if (ready) {    return `${name.toUpperCase()}: $${price.toFixed(2)}`;  }  return "Loading";} console.log(priceLabel("tea", 3));console.log(priceLabel(undefined, 3));

Line 2 saves a check of both parameters in ready. Line 3 tests ready, and TypeScript narrows name and price as if the whole check were written there, so line 4 can call toUpperCase and toFixed. Lines 9 and 10 print TEA: $3.00 and Loading.

It works only when nothing can change between the check and the test:

  • the saved check is in a const;
  • what it checks is a const, a readonly property, or a parameter that is never assigned in the function.
The same check saved in a let (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
function priceLabel(name: string | undefined, price: number | undefined) {  let ready = name !== undefined && price !== undefined;  if (ready) {    return `${name.toUpperCase()}: $${price.toFixed(2)}`;  }  return "Loading";}

Only line 2 changed: let instead of const. Line 4 now gets two errors on purpose, 'name' is possibly 'undefined' and 'price' is possibly 'undefined'. A let could be given a new value between lines 2 and 3, so TypeScript doesn't trust what it holds.

A saved check also works for a discriminated union, a union whose members share a property with a different value in each:

A discriminant pulled out of a replyPop out in the code editor (opens in a new tab)TypeScript
type Reply = { ok: true; items: string[] } | { ok: false; message: string }; function showReply(reply: Reply) {  const { ok } = reply;  if (ok) {    return `${reply.items.length} results`;  }  return reply.message;} console.log(showReply({ ok: true, items: ["Tea", "Cake"] }));

Line 1 describes a reply that has ok: true and items, or ok: false and a message. Line 4 pulls ok out with destructuring. Line 5 tests it, and TypeScript narrows reply itself, so line 6 can read reply.items. Line 11 prints 2 results.

A function can save a check too. Type guards showed that since TypeScript 5.5, a simple check with no return type becomes a type predicate, like score is number. TypeScript decides by following both paths out of the function: true must mean the type, and false must mean not that type.

One predicate inferred, one notPop out in the code editor (opens in a new tab)TypeScript
const scores = [4, undefined, 0, 5]; const isScore = (score: number | undefined) => score !== undefined;//    ^?const isTruthy = (score: number | undefined) => !!score;//    ^? const kept = scores.filter(isScore);//    ^?console.log(kept);
  1. Line 3: the query on line 4 shows (score: number | undefined) => score is number. When it returns true, score is a number. When it returns false, it's undefined. Both line up, so TypeScript writes the predicate for you.
  2. Line 5: the query on line 6 shows plain boolean. When !!score is false, score could be undefined or 0, and 0 is a number. False doesn't mean "not a number", so there's no predicate.
  3. Line 8 filters with isScore, and the query on line 9 shows number[]. Line 10 prints [ 4, 0, 5 ]: the zero stays.

Used before assigned, and code that never runs

The same paths answer two more questions: was a variable given a value before it's read, and can a line be reached at all? The first is called definite assignment analysis:

A fee stored on one path (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
function shippingFee(express: boolean) {  let fee: number | string;  if (express) {    fee = 10;  }  return fee;}

Line 2 declares fee with no value. Line 4 stores 10 only when express is true. Line 6 reads fee, and TypeScript marks it on purpose: Variable 'fee' is used before being assigned. In plain words: on the path where express is false, nothing was stored, so fee would be undefined, which isn't a number or a string.

Line 2: let fee. Line 3 true leads to line 4: fee = 10. Line 3 false leads to a card saying fee is not assigned. Both lead to line 6: return fee, marked used before being assigned.line 2: let feeline 3 trueline 4: fee = 10line 3 falsenothing storedline 6: return feeused before being assigned
The true path stores 10 in fee. The false path skips line 4 and stores nothing. Both reach line 6, so on one of them fee is read before anything was stored.

Store a value on every path, with an else or a starting value, and the error goes. You can also write let fee!: number | string;. The ! after the name turns the check off for that variable. Like the non-null assertion after a value, it's a promise TypeScript can't check.

A path ends at return and throw, and also at a call to a function that returns never:

A call that ends the pathPop out in the code editor (opens in a new tab)TypeScript
function fail(message: string): never {  throw new Error(message);} function priceText(price: number | undefined) {  if (price === undefined) fail("No price");  return price.toFixed(2);} console.log(priceText(3));

Line 1 says fail returns never: it always throws, so it never returns. Line 6 calls it when price is undefined. That call ends the path, like a throw, so line 7 sees a number. Line 10 prints 3.00.

An arrow that doesn't end the path (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
const fail = (message: string): never => {  throw new Error(message);}; function priceText(price: number | undefined) {  if (price === undefined) fail("No price");  return price.toFixed(2);}

The same fail, written as an arrow function in a const. Line 7 now gets 'price' is possibly 'undefined', on purpose. So that it doesn't have to work out other types first, TypeScript only treats a call as the end of a path when the call is its own statement and the function's type is written where the function is declared. Line 1 writes the arrow's return type, but no type on const fail. function fail(message: string): never or const fail: (message: string) => never = … both work.

Finally, code that no path reaches at all:

Code after a return (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
function cartTotal(prices: number[]) {  return prices.reduce((sum, price) => sum + price, 0);  console.log("Total worked out");} console.log(cartTotal([3, 4]));

Line 2 returns, so no path reaches line 3. By default, editors show such lines faded and TypeScript reports nothing. This example sets allowUnreachableCode: false, a tsconfig.json setting (see tsconfig strictness), so line 3 gets Unreachable code detected, on purpose. Line 6 prints 7.

A search box that waits before it searches

A search box often waits 300 milliseconds after the last key press before it sends a search, so it doesn't send one per letter. The waiting is a callback, and that's where narrowing goes missing. A first version:

A search that reads a property later (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
type Search = { query?: string; page: number }; function runSearch(search: Search, send: (text: string) => void) {  if (search.query === undefined || search.query.trim() === "") {    return "Type something to search";  }  setTimeout(() => send(`${search.query.trim()}, page ${search.page}`), 300);  return `Searching for ${search.query.trim()}`;}

Line 1 describes a search with an optional query and a page number. Line 4 returns early when there's no query, or only spaces. Line 7 sends the search 300 milliseconds later, from a callback, and reads search.query inside it. TypeScript marks line 7 on purpose: 'search.query' is possibly 'undefined', because a property check never reaches into a callback. Line 8 is fine: it isn't in a callback.

The search with a const copy and a saved checkPop out in the code editor (opens in a new tab)TypeScript
type Search = { query?: string; page: number }; function runSearch(search: Search, send: (text: string) => void) {  const query = search.query?.trim();  const hasQuery = query !== undefined && query !== "";  if (!hasQuery) {    return "Type something to search";  }  setTimeout(() => send(`${query}, page ${search.page}`), 300);  return `Searching for ${query}`;} console.log(runSearch({ page: 1 }, console.log));console.log(runSearch({ query: " tea ", page: 2 }, console.log));
  1. Line 4 copies the trimmed query into a const. ?. gives undefined when there's no query (see optional chaining).
  2. Line 5 saves the check in a const, hasQuery, and line 6 returns early when it's false.
  3. Line 9's callback reads query, a const that is a string after line 6, so there's no error.

Line 13 prints Type something to search. Line 14 prints Searching for tea, and 300 milliseconds later the callback sends tea, page 2, which this example prints with console.log.

Error or fine?
  • function f(q: string | undefined) { if (q) setTimeout(() => q.length); }
  • function f(q: string | undefined) { if (q) setTimeout(() => q.length); q = undefined; }
  • function f(c: { q?: string }) { if (c.q) setTimeout(() => c.q.length); }
  • function f(c: { q?: string }, reset: () => void) { if (c.q) { reset(); c.q.length; } }
  • function f(x: string | number) { const isText = typeof x === "string"; if (isText) x.length; }
  • function f(x: string | number) { let isText = typeof x === "string"; if (isText) x.length; }
  • let total: number; if (Math.random() > 0.5) total = 1; console.log(total);
  • function f(x: string | number) { if (typeof x === "string") return; if (typeof x === "number") return; const gone: never = x; }
Try it yourself
0 of 8 correct

Sort each line by what TypeScript says about it, with strict on. 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

The table below sums up what keeps a narrowing. Read each row as: what happens between a check and the place that uses it, whether the narrowing survives, and why.

What keeps a narrowing, and what resets it
Between the check and the useIs the narrowing kept?Why
Nothing that touches the variableYes.No step in the flow changes it.
An assignment to the variable, the property, or the object it belongs toNo.An assignment is a step in the flow, and it replaces the narrowed type.
A function call, even one that changes the propertyYes.The checker doesn't look inside the functions you call. It assumes they change nothing.
An awaitYes.The checker doesn't assume other code changed your values while it waited.
A callback that reads a constYes.A const can't be assigned again, so the check still holds whenever the callback runs.
A callback that reads a let or a parameterYes, if no assignment comes after the callback is made (TypeScript 5.4 and later).Otherwise the callback might run after a later assignment.
A callback that reads a property, like search.queryNo.Any code could change the property before the callback runs.
  • "TypeScript runs my code to find the types." No. It never runs it. It reads the paths the code can take and works out the types along them.
  • "Once a variable is narrowed, it stays narrowed." No. An assignment replaces the narrowed type, and inside a callback the narrowing may not apply at all.
  • "A call that changes a property resets its narrowing." No. The checker assumes calls change nothing, so the narrowing survives and the code can crash with no error. Copy the value into a const first.
  • "Checking one variable narrows another that holds the same value." No. Each reference is tracked on its own. After let text = title, checking title doesn't narrow text.
  • "A saved check works from any variable." No. It must be saved in a const, and what it checks must not be assigned in the function.
  • "let fee!: number makes fee safe to read." No. The ! only turns the check off. If no path stores a value, reading it still gives undefined.

Practice exercises

Each exercise is TypeScript. When you fix code, press Edit & run on the starter code: the errors update as you type, and the console shows what it prints. Follow the paths first, then use the hints.

Exercise 1 · Warm-upRead the type where two paths meet

What type does TypeScript give size on line 5?

Starter codePop out in the code editor (opens in a new tab)TypeScript
function sizeLabel(size: number | string | undefined) {  if (size === undefined) {    size = 0;  }  return size;} console.log(sizeLabel(undefined), sizeLabel("M"));

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

    Exercise 2 · Warm-upFind the read before an assignment

    Which line will TypeScript mark, and why?

    Starter codePop out in the code editor (opens in a new tab)TypeScript
    function cartMessage(items: number) {
      let message: string;
      if (items === 0) {
        message = "Your cart is empty";
      } else if (items > 0) {
        message = `${items} items`;
      }
      return message;
    }

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

      Exercise 3 · PracticeFix a property read in a callback

      TypeScript marks line 5. Fix it without a type assertion. What does your fixed code print?

      Starter codePop out in the code editor (opens in a new tab)TypeScript
      type Profile = { name: string; nickname?: string }; function greetLater(profile: Profile) {  if (profile.nickname === undefined) return;  setTimeout(() => console.log(`Hi ${profile.nickname.toUpperCase()}`), 0);} greetLater({ name: "Asha", nickname: "ash" });

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

        Exercise 4 · PracticeMake a saved check narrow

        TypeScript says count is possibly undefined on line 4, even though line 3 tests the check. Fix it. What does it print?

        Starter codePop out in the code editor (opens in a new tab)TypeScript
        function badge(count: number | undefined, label: string | undefined) {  let show = count !== undefined && label !== undefined;  if (show) {    return `${label}: ${count + 1}`;  }  return "";} console.log(badge(2, "Cart"));

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

          Exercise 5 · ChallengeSurvive a call that clears the coupon

          This code has no type errors, and it crashes. Change checkout so it prints the coupon that was used. What does it print?

          Starter codePop out in the code editor (opens in a new tab)TypeScript
          type Cart = { items: number; coupon?: string }; function useCoupon(cart: Cart) {  cart.coupon = undefined;} function checkout(cart: Cart) {  if (cart.coupon === undefined) return "Paid in full";  useCoupon(cart);  return `Paid with ${cart.coupon.toUpperCase()}`;} console.log(checkout({ items: 2, coupon: "tea10" }));

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

            Exercise 6 · ChallengeFilter so the zero stays

            TypeScript marks line 4, and the starter drops the score of 0. Fix the filter on line 3. What does it print?

            Starter codePop out in the code editor (opens in a new tab)TypeScript
            const scores = [4, undefined, 0, 5]; const kept = scores.filter((score) => !!score);console.log(kept.map((score) => score.toFixed(1)).join(", "));

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

              Check your understanding

              For each question, follow the paths that reach the line. The quiz shows its code without the checker, so the error list doesn't give the answers away.

              The control flow analysis quiz · 7 questionsScore: first tries count
              1. Question 1 of 7TypeScript needs the type of coupon on one line of a function. What does it do?

                Choose an answer to see the explanation.

              2. Question 2 of 7What type does TypeScript give text on line 6?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                function pageTitle(title: string | null, count: number) {
                  let text: string | number | null = title;
                  if (title === null) {
                    text = count;
                  }
                  return text;
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7Which line of this saved-choice code does TypeScript flag?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                function remember(choice: string | undefined) {
                  if (choice === undefined) return;
                  const save = () => localStorage.setItem("choice", choice);
                  choice = choice.trim();
                  setTimeout(save, 0);
                }

                Choose an answer to see the explanation.

              4. Question 4 of 7After if (cart.coupon !== undefined), the code calls clearCoupon(cart), which sets cart.coupon to undefined. What type does cart.coupon have on the next line?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does TypeScript say about line 4 of this price function?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                function total(price: number | undefined) {
                  let known = price !== undefined;
                  if (known) {
                    return price * 2;
                  }
                  return 0;
                }

                Choose an answer to see the explanation.

              6. Question 6 of 7A let total: number is assigned only inside an if, then read after the if. What does TypeScript report on the read?

                Choose an answer to see the explanation.

              7. Question 7 of 7What type does TypeScript work out for (score: number | undefined) => !!score?

                Choose an answer to see the explanation.

              Key takeaways

              • TypeScript works out a variable's type on each line from the paths that reach it. It never runs your code.
              • The binder records the paths as flow nodes. The checker walks back along them from each line that uses a variable.
              • Checks split the flow, assignments replace the type, return and throw end a path, and joins take the union.
              • Callbacks keep a const's narrowing, and a let's only when no assignment comes after them. Property narrowing is never kept there.
              • Calls and await don't reset narrowing, even when they change the value. Copy into a const when that matters.
              • A check saved in a const still narrows, and simple checks in functions become predicates.
              • The same paths find variables used before they're assigned, and code no path reaches.

              Remember the one-liner.
              The type on a line is what every path to that line agrees on.

              Coming next: Limits & soundness. It looks at the places where TypeScript lets unsafe code through on purpose, like the call that cleared the coupon here, and why.

              CompleteFrontend Clear concepts. Working examples.