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.
- 01Trace 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.
- 02Predict when narrowing is lostExplain why callbacks, assignments, and saved checks keep or lose a narrowing, and why calls and awaits don't reset it.
- 03Fix 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:
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.
- The query on line 8 points at
couponon line 7 and showsstring | Coupon:nullis gone. - Inside line 7's
if, the query on line 10 showsstring, so line 9 can calltoUpperCase(). Line 9 then stores a newCouponobject incoupon. - The query on line 13 shows
Couponon line 12: every path that reaches line 12 brings a coupon object, socoupon.codeis 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:
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.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 andreturns - 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
returnor athrow - 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
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.
| The code | The step it adds | What happens to the type |
|---|---|---|
A parameter or a declared variable, like coupon: Coupon | string | null | The 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 case | Two 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 loop | A 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 never | The 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:
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`;}Start: the declared type3string | Coupon | nullLine 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.
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.
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:
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:
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:
function scheduleSearch(query: string | undefined) { if (query === undefined) return; setTimeout(() => { console.log(`Searching for ${query.toUpperCase()}`); }, 0); console.log("Search scheduled");} scheduleSearch("tea");console.log("Search scheduled");No errorsprints Search scheduled, then Searching for TEANo 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.
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.
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:
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:
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" }));console.log("Applying coupon");No errorsprints Applying coupon, then TEA10Nothing between line 5's check and line 7 touches cart.coupon, so line 7 reads a string. The code prints Applying coupon, then TEA10.
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:
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.
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, areadonlyproperty, or a parameter that is never assigned in the function.
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:
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.
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);- Line 3: the query on line 4 shows
(score: number | undefined) => score is number. When it returns true,scoreis a number. When it returns false, it'sundefined. Both line up, so TypeScript writes the predicate for you. - Line 5: the query on line 6 shows plain
boolean. When!!scoreis false,scorecould beundefinedor0, and0is a number. False doesn't mean "not a number", so there's no predicate. - Line 8 filters with
isScore, and the query on line 9 showsnumber[]. 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:
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.
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:
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.
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:
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:
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.
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));- Line 4 copies the trimmed query into a
const.?.givesundefinedwhen there's no query (see optional chaining). - Line 5 saves the check in a
const,hasQuery, and line 6 returns early when it's false. - Line 9's callback reads
query, aconstthat is astringafter 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.
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; }
Sort each line by what TypeScript says about it, with strict on. Each line is checked on its own.
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.
| Between the check and the use | Is the narrowing kept? | Why |
|---|---|---|
| Nothing that touches the variable | Yes. | No step in the flow changes it. |
| An assignment to the variable, the property, or the object it belongs to | No. | An assignment is a step in the flow, and it replaces the narrowed type. |
| A function call, even one that changes the property | Yes. | The checker doesn't look inside the functions you call. It assumes they change nothing. |
An await | Yes. | The checker doesn't assume other code changed your values while it waited. |
A callback that reads a const | Yes. | A const can't be assigned again, so the check still holds whenever the callback runs. |
A callback that reads a let or a parameter | Yes, 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.query | No. | 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
constfirst. - "Checking one variable narrows another that holds the same value." No. Each reference is tracked on its own. After
let text = title, checkingtitledoesn't narrowtext. - "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!: numbermakesfeesafe to read." No. The!only turns the check off. If no path stores a value, reading it still givesundefined.
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.
What type does TypeScript give size on line 5?
function sizeLabel(size: number | string | undefined) { if (size === undefined) { size = 0; } return size;} console.log(sizeLabel(undefined), sizeLabel("M"));function sizeLabel(size: number | string | undefined) { if (size === undefined) { size = 0; } return size;// ^?} console.log(sizeLabel(undefined), sizeLabel("M"));The path through line 3 brings a number. The other path skips the if only when size isn't undefined, so it brings string | number. The join is string | number, as the query on line 6 shows. It prints 0 M.
Which line will TypeScript mark, and why?
function cartMessage(items: number) {
let message: string;
if (items === 0) {
message = "Your cart is empty";
} else if (items > 0) {
message = `${items} items`;
}
return message;
}function cartMessage(items: number) { let message: string; if (items === 0) { message = "Your cart is empty"; } else { message = `${items} items`; } return message;} console.log(cartMessage(0), cartMessage(3));TypeScript marks line 8: Variable 'message' is used before being assigned. When items is negative, neither branch runs, and nothing is stored. An else stores a message on every path. The fixed code prints Your cart is empty 3 items.
TypeScript marks line 5. Fix it without a type assertion. What does your fixed code print?
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" });type Profile = { name: string; nickname?: string }; function greetLater(profile: Profile) { const nickname = profile.nickname; if (nickname === undefined) return; setTimeout(() => console.log(`Hi ${nickname.toUpperCase()}`), 0);} greetLater({ name: "Asha", nickname: "ash" });The const nickname keeps its narrowing inside the callback, because a const can't change. The error is gone, and the timer prints Hi ASH.
TypeScript says count is possibly undefined on line 4, even though line 3 tests the check. Fix it. What does it print?
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"));function badge(count: number | undefined, label: string | undefined) { const show = count !== undefined && label !== undefined; if (show) { return `${label}: ${count + 1}`; } return "";} console.log(badge(2, "Cart"));With const show, testing show narrows count and label, so count + 1 is fine. It prints Cart: 3.
This code has no type errors, and it crashes. Change checkout so it prints the coupon that was used. What does it print?
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" }));type Cart = { items: number; coupon?: string }; function useCoupon(cart: Cart) { cart.coupon = undefined;} function checkout(cart: Cart) { const coupon = cart.coupon; if (coupon === undefined) return "Paid in full"; useCoupon(cart); return `Paid with ${coupon.toUpperCase()}`;} console.log(checkout({ items: 2, coupon: "tea10" }));The checker assumes useCoupon(cart) changes nothing, so the starter has no error but throws a TypeError. The const coupon holds the code from before the call, so it prints Paid with TEA10.
TypeScript marks line 4, and the starter drops the score of 0. Fix the filter on line 3. What does it print?
const scores = [4, undefined, 0, 5]; const kept = scores.filter((score) => !!score);console.log(kept.map((score) => score.toFixed(1)).join(", "));const scores = [4, undefined, 0, 5]; const kept = scores.filter((score) => score !== undefined);console.log(kept.map((score) => score.toFixed(1)).join(", "));score !== undefined is true for every number and false only for undefined, so TypeScript works out the predicate score is number. kept is number[], the zero stays, and it prints 4.0, 0.0, 5.0.
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.
Question 1 of 7TypeScript needs the type of
couponon one line of a function. What does it do?Choose an answer to see the explanation.
Question 2 of 7What type does TypeScript give
texton line 6?Read the code, then predictfunction 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.
Question 3 of 7Which line of this saved-choice code does TypeScript flag?
Read the code, then predictfunction 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.
Question 4 of 7After
if (cart.coupon !== undefined), the code callsclearCoupon(cart), which setscart.coupontoundefined. What type doescart.couponhave on the next line?Choose an answer to see the explanation.
Question 5 of 7What does TypeScript say about line 4 of this price function?
Read the code, then predictfunction total(price: number | undefined) { let known = price !== undefined; if (known) { return price * 2; } return 0; }Choose an answer to see the explanation.
Question 6 of 7A
let total: numberis assigned only inside anif, then read after theif. What does TypeScript report on the read?Choose an answer to see the explanation.
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,
returnandthrowend a path, and joins take the union. - Callbacks keep a
const's narrowing, and alet's only when no assignment comes after them. Property narrowing is never kept there. - Calls and
awaitdon't reset narrowing, even when they change the value. Copy into aconstwhen that matters. - A check saved in a
conststill 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.