Habits of Good TypeScript
The habits that make TypeScript catch more: let inference work, type the edges, prefer unknown to any, narrow instead of casting, and model states with unions.
- 01Write types where they countLet TypeScript work out types inside your code, and write them at the edges: parameters, exported return types, and data from outside.
- 02Keep the checker onUse unknown, narrowing, and real checks instead of any, as, and !, so mistakes are still reported.
- 03Model what can really happenReplace bags of optional flags with unions, check every case with never, and keep types small, precise, and fast to check.
Habits that let TypeScript help
TypeScript can only check what your types tell it. Here is a cart total with a typo in it, prise instead of price, and the type any on its items:
function cartTotal(items: any) { return items.reduce((sum: number, item: any) => sum + item.price, 0);} const total = cartTotal([{ name: "Tea", prise: 3 }]);console.log(total);Line 1 says items is any, which means "anything at all, don't check it". Line 2 adds up item.price for each item. Line 5 passes an item with prise. There are no errors, and line 6 prints NaN, because item.price is undefined. Now the same code with a real type, and an error on purpose:
type CartItem = { name: string; price: number }; function cartTotal(items: CartItem[]) { return items.reduce((sum, item) => sum + item.price, 0);} const total = cartTotal([{ name: "Tea", prise: 3 }]);console.log(total);Line 1 describes a CartItem. Line 3 says items is a list of them, and line 4 needs no types at all: TypeScript works out sum and item from line 3. Line 7 is marked: Object literal may only specify known properties, but 'prise' does not exist in type 'CartItem'. Did you mean to write 'price'? The code still prints NaN if you run it, but you'd fix it before anyone does.
prise, in both. With items: any, TypeScript has nothing to check, and the total is NaN. With CartItem[], it marks the typo while you type.Both versions are valid TypeScript. The difference is a habit. This lesson collects the habits that keep TypeScript checking, each with a before and an after, and links to the lesson that teaches it in full.
A smoke alarm beeps when you burn the toast. You could take the battery out, and the beeping would stop. The toast would still be burning. Opening the window clears the smoke, and the alarm stops because there's nothing to warn about. Good habits are the second kind: they deal with the problem, and the alarm keeps working for the next one.
- In real life: The alarm goes off when there's smoke
- In TypeScript: TypeScript reports a value that doesn't fit
- In real life: Taking the battery out stops the noise
- In TypeScript:
any,as, and!stop the error - In real life: Opening a window clears the smoke
- In TypeScript: A check like
?? 0handles the value - In real life: An alarm in every room, not just one
- In TypeScript: Types at every edge, not just a few
Good TypeScript keeps the checker on. Let TypeScript work out types inside your code, and write them at the edges. Handle a value that may be wrong instead of silencing the error. Make types say exactly what can happen, so wrong states can't be written at all.
Let inference work, annotate the edges
You don't need to write a type on every name. TypeScript works out the type from the value, which is called inference. This code writes every type by hand:
const taxRate: number = 0.2;const items: string[] = ["Tea", "Cake"];const count: number = items.length;console.log(count);Lines 1 to 3 each say a type the value already shows: 0.2 is a number, the list holds strings, and .length is a number. Line 4 prints 2. Here it is with no annotations, and a query under each name:
const taxRate = 0.2;// ^?const items = ["Tea", "Cake"];// ^?const count = items.length;// ^?console.log(count);The query on line 2 answers const taxRate: 0.2, which is even more exact: a const can never change, so its type is the value itself. Line 4 answers const items: string[] and line 6 const count: number. Same checks, less to read. (Annotations and inference covers the rules.)
An annotation can even make a type worse. Switch line 4 between three versions:
type Status = "paid" | "shipped";function ship(status: Status) { console.log(`Order is ${status}`); } const status: string = "paid";ship(status);// ^?string1 error: line 5prints Order is paid: string tells TypeScript to forget that the value is exactly "paid". Line 5 then fails: Argument of type 'string' is not assignable to parameter of type 'Status'. The annotation made the type worse.
With : string, line 5 is marked: Argument of type 'string' is not assignable to parameter of type 'Status'. The annotation threw away the fact that the value is exactly "paid" (Literal types). With no annotation, TypeScript keeps it, and the error is gone.
So where do you write types? At the edges: the places where values come in or go out, and where TypeScript has nothing to learn from.
- Function parameters. A parameter has no value until someone calls the function.
- The return type of a function other files use, so a mistake inside it is marked inside it.
- Data from outside your code, like a server reply (more on that below).
type Summary = { count: number; total: number }; export function summarize(prices: number[]): Summary { const total = prices.reduce((sum, price) => sum + price, 0); // ^? return { count: prices.length, total };} console.log(summarize([3, 4]));Line 1 names the result's type. Line 3 writes the two edges: the parameter prices: number[] and the return type Summary. Line 4 has no annotation, and the query on line 5 shows const total: number. Line 6 returns the count and the total, and line 9 prints { count: 2, total: 7 }. If line 6 spelled totl, the return type would mark it right there, as Reading type errors showed.
summarize from the example. You write the types where data comes in and goes out. TypeScript works out the rest, like total.Handle a value instead of silencing the error
Three things turn TypeScript's checks off: the type any, a type assertion like value as Product, and the non-null assertion value!. Each one tells TypeScript "trust me". None of them runs any code: they're removed with the types.
Start with any. When you don't know a value's type yet, like the result of JSON.parse, use unknown instead. It means "could be anything, so check it first". This reply is used without a check, and TypeScript marks it on purpose:
const data: unknown = JSON.parse('{"name":"Tea","price":3}');console.log(data.price);Line 1 parses the reply and gives it the type unknown. Line 2 reads data.price, and TypeScript says 'data' is of type 'unknown': prove what it is first. With any, line 2 would pass, and so would data.prise. Add the proof:
const data: unknown = JSON.parse('{"name":"Tea","price":3}');if (typeof data === "object" && data !== null && "price" in data) { console.log(data.price);}Line 2 checks that data is an object, isn't null, and has a price. Inside the if, TypeScript allows data.price, and line 3 prints 3. This is narrowing: a check that TypeScript understands and that really runs. any, unknown, and never compares the two types.
Now ! and as. A Map gives back undefined for a key it doesn't have (Map and Set). Try four ways to deal with that on line 5:
const stock = new Map([["tea", 5]]); function nextCount(id: string) { const count = stock.get(id); return count + 1;}console.log(nextCount("cake"));NaN1 error: line 5TypeScript marks line 5: 'count' is possibly 'undefined'. The stock has no cake, so get gives back undefined, and undefined + 1 is NaN.
Read the output, not just the error list. count! + 1 and (count as number) + 1 both have no errors and still print NaN: they changed what TypeScript believes, not what the code does. Only (count ?? 0) + 1 handles the missing count, and prints 1 (Nullish coalescing). Use ! and as only when you know something TypeScript can't, and say why in a comment. Type assertions covers the few places they belong.
Make wrong states impossible
A product list loads from a server. It can be loading, failed, or done. A common first type uses a flag and two optional properties:
type ProductsRequest = { loading: boolean; error?: string; products?: string[];}; const request: ProductsRequest = { loading: true, error: "Timed out", products: ["Tea"] };console.log(request.loading);Lines 1 to 5 say a request has a loading flag, maybe an error, and maybe some products. Line 7 makes a request that is loading, failed, and done at the same time. That can't happen, but the type allows it, so there's no error. Every piece of code that reads a request has to guess which mix it got. Line 8 prints true.
Instead, list the states. Give each one a status with its own exact value, and only the properties that state has. This is a discriminated union:
type ProductsRequest = | { status: "loading" } | { status: "error"; error: string } | { status: "success"; products: string[] }; const request: ProductsRequest = { status: "loading", error: "Timed out" };console.log(request.status);Lines 1 to 4 say a request is exactly one of three things. Only an error has an error, and only a success has products. Line 6 tries the impossible mix again, and TypeScript marks it: Object literal may only specify known properties, and 'error' does not exist in type '{ status: "loading"; }'. A loading request has no error. The wrong state can't be written.
A union pays off again when you add a state. A switch can end with a default that stores the value in a variable of type never, the type with no values. If every state has a case, nothing reaches default, and that's fine. Add a state and watch:
type ProductsRequest = | { status: "loading" } | { status: "error"; error: string } | { status: "success"; products: string[] }; function message(request: ProductsRequest): string { switch (request.status) { case "loading": return "Loading…"; case "error": return `Failed: ${request.error}`; case "success": return `${request.products.length} products`; default: { const unhandled: never = request; return unhandled; } }}console.log(message({ status: "success", products: ["Tea", "Cake"] }));2 productsNo errorsEvery state has a case. In default, request has no states left, so its type is never, and storing it in unhandled: never is fine.
At step 2, the union gets an "empty" state on line 5, and line 16 is marked: Type '{ status: "empty"; }' is not assignable to type 'never'. The error names the state the switch forgot. Step 3 adds its case, and the error is gone. In a real app, this finds every switch that needs the new state, across every file. (Exhaustiveness checking shows other ways to write it.)
Small, precise types
A precise type says exactly what's allowed: "light" | "dark" rather than string. Three tools help keep types precise.
satisfies for config objects
For a settings object, you want two things: a check against Settings, and the exact values you wrote. An annotation gives only the first. satisfies gives both:
type Theme = "light" | "dark";type Settings = { theme: Theme; pageSize: number }; const settings = { theme: "dark", pageSize: 20,} satisfies Settings;const theme = settings.theme;// ^?console.log(theme);Lines 1 and 2 describe the settings. Lines 4 to 7 make them and check them with satisfies Settings. Line 8 reads the theme, and the query on line 9 answers const theme: "dark", the exact value. With const settings: Settings, it would be the wider Theme. The check still works, on purpose here:
type Theme = "light" | "dark";type Settings = { theme: Theme; pageSize: number }; const settings = { theme: "drak", pageSize: 20,} satisfies Settings;Line 5 spells "drak", and TypeScript marks it: Type '"drak"' is not assignable to type 'Theme'. (as const and satisfies covers both.)
readonly where a change would be a bug
A function that gets an array from someone else shouldn't change it. Say so with readonly, and TypeScript holds you to it, on purpose here:
function halfPrice(prices: readonly number[]) { for (let i = 0; i < prices.length; i++) { prices[i] = prices[i] / 2; } return prices;} const menu = [4, 6];console.log(halfPrice(menu), menu);Line 1 takes prices as a readonly number[]. Line 3 writes a new price into it, and TypeScript says Index signature in type 'readonly number[]' only permits reading. The bug is real: run it, and line 9 prints the halved prices and a menu that is halved too, [2, 3]. Return a new array instead:
function halfPrice(prices: readonly number[]) { return prices.map((price) => price / 2);} const menu = [4, 6];console.log(halfPrice(menu), menu);Line 2 builds a new array with map (map, filter, and reduce). No errors, and line 6 prints [2, 3] and the menu unchanged, [4, 6]. (Arrays and tuples covers readonly arrays.)
An as const object instead of an enum
An enum is one of the few TypeScript features that adds JavaScript of its own, so tools that only remove types can't run it (Enums explains this). A plain object with as const gives you the same names, and a union type from its values:
const OrderStatus = { Paid: "paid", Shipped: "shipped",} as const;type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];// ^? function label(status: OrderStatus) { return status === OrderStatus.Paid ? "Paid" : "On its way";}console.log(label("shipped"));Lines 1 to 4 are an ordinary object, made as const so each value keeps its exact text. Line 5 builds a type from its values, and the query on line 6 answers type OrderStatus = "paid" | "shipped". Line 9 compares with OrderStatus.Paid, as an enum would. Line 11 prints On its way.
Check data where it comes in
First, turn "strict": true on in tsconfig.json. It turns on the checks this lesson relies on, like strictNullChecks (which reports values that may be null or undefined) and noImplicitAny (which asks for parameter types). Strict mode options lists them all.
Strict checks can't see inside data that arrives while the page runs: a server reply, localStorage, a form, the address bar. That data is a boundary. Check it once, there, and the rest of your code can trust it. Compare a promise with a check:
type Product = { name: string; price: number }; function isProduct(data: unknown): data is Product { return ( typeof data === "object" && data !== null && "name" in data && typeof data.name === "string" && "price" in data && typeof data.price === "number" );} const text = '{"name":"Tea","price":"3"}';const product = JSON.parse(text) as Product;console.log(product.price + 1);31No errors{"name":"Tea","price":"3"}as Product is a promise TypeScript can't check. There are no errors for any reply, so a price that arrives as text joins with 1 into "31", and an empty object gives NaN.
Lines 3 to 9 are isProduct, a type guard: a function whose return type data is Product tells TypeScript what a true answer means. With as Product, every reply has no errors, and a price sent as text prints 31. With isProduct check, the same reply prints Not a product, and only a real number reaches data.price + 1.
isProduct, the code can trust the price. A reply that fails the check never reaches it.Larger apps often use a library that builds these checks from a description of the data. Typing fetch and JSON shows both ways.
Types that stay fast to check
In a big project, the editor can slow down: errors take a while to appear, and hovering lags. Most small apps never get there. When yours does, measure first. This command prints how much work the check did:
npx tsc --noEmit --extendedDiagnosticsIts report includes counts like Types and Instantiations, and times like Check time. Run it before and after a change to see whether the change helped. The TypeScript team's performance guide suggests a few habits. The first one also gives better errors. Compare an intersection, &, with an interface that extends another:
type Product = { id: number; name: string };type CartLine = Product & { id: string; quantity: number }; const line: CartLine = { id: "7", name: "Tea", quantity: 2 };Line 2 combines Product with a type whose id is a string, but Product's id is a number. Nothing is reported there. A value can't be both, so the combined id is never, and line 4 gets the confusing Type 'string' is not assignable to type 'never'.
interface Product { id: number; name: string }interface CartLine extends Product { id: string; quantity: number } const line: CartLine = { id: "7", name: "Tea", quantity: 2 };Now line 2 itself is marked: Interface 'CartLine' incorrectly extends interface 'Product', ending in Type 'string' is not assignable to type 'number'. The error is where the mistake is. The interface is also one flat type, which the performance guide says the compiler checks faster than an intersection. (Intersection types compares the two.)
| When | Try | Why it helps |
|---|---|---|
| You combine object types | interface B extends A instead of A & {…} | The compiler builds one flat type and reports clashes where you wrote them. |
| An exported function returns a big type | Name the type and write it as the return type | The compiler and your editor reuse the name instead of working the type out again. |
| A union grows to dozens of members | A base type with the shared properties | Each value is compared against fewer types. |
Checking node_modules takes long | "skipLibCheck": true | TypeScript stops checking every .d.ts file, which are usually already right. |
| Every check starts from scratch | "incremental": true | TypeScript saves what it learned in a .tsbuildinfo file and checks only what changed. |
Reviewing a checkout module
Here is a checkout screen as it might arrive in a code review. It has no type errors, and it throws when it runs:
type Checkout = { paid: boolean; failed?: boolean; receipt?: string }; function show(checkout: Checkout) { if (checkout.paid) return `Receipt ${checkout.receipt!.toUpperCase()}`; return checkout.failed ? "Payment failed" : "Waiting";} const saved: any = JSON.parse('{"paid":true}');console.log(show(saved));Line 1 uses flags. Line 4 reads checkout.receipt!, promising the receipt is there. Line 8 parses saved data as any. The saved checkout says it's paid but has no receipt, so line 4 throws a TypeError. Each habit from this lesson removes one way this can happen:
type Checkout = | { status: "waiting" } | { status: "failed" } | { status: "paid"; receipt: string }; function show(checkout: Checkout): string { switch (checkout.status) { case "paid": return `Receipt ${checkout.receipt.toUpperCase()}`; case "failed": return "Payment failed"; case "waiting": return "Waiting"; }} function isCheckout(data: unknown): data is Checkout { if (typeof data !== "object" || data === null || !("status" in data)) return false; if (data.status === "paid") return "receipt" in data && typeof data.receipt === "string"; return data.status === "waiting" || data.status === "failed";} const saved: unknown = JSON.parse('{"status":"paid"}');console.log(isCheckout(saved) ? show(saved) : "Unknown checkout");Lines 1 to 4 list the states, and only a paid checkout has a receipt. Line 6 writes the return type. The switch on lines 7 to 14 reads checkout.receipt with no !, because inside case "paid" TypeScript knows it exists. Lines 17 to 21 check saved data, and line 23 types it unknown. The same broken data now prints Unknown checkout instead of throwing.
The whole lesson in one table. Read each row as: what to look for, what to write instead, and the lesson that teaches it.
| Instead of | Write | Where to learn it |
|---|---|---|
const items: string[] = ["Tea"] | const items = ["Tea"], and types on parameters and exported return types | Type annotations & inference |
data: any | data: unknown, then narrow it | any, unknown & never |
value! or value as Product | A check: if, ??, ?., or a type guard | Narrowing with typeof & truthiness; Type assertions |
{ loading: boolean; error?: string } | A union with a status in each member | Discriminated unions |
A switch that skips a case | A default that stores the value in a never | Exhaustiveness checking |
const settings: Settings = {…} | const settings = {…} satisfies Settings | as const & satisfies |
| A parameter the function changes by accident | readonly number[] | Arrays & tuples |
enum Status { … } | An as const object and a union of its values | Enums |
JSON.parse(text) as Order | unknown, and a function that checks it | Type predicates & assertion functions; Typing fetch & JSON |
"strict": false | "strict": true in tsconfig.json | Strict mode options |
Now sort some lines from a review. Does each one keep TypeScript checking, or switch it off?
const data: unknown = JSON.parse(text);const data: any = JSON.parse(text);const count = stock.get("tea") ?? 0;const count = stock.get("tea")!;const user = JSON.parse(text) as User;const settings = { theme: "dark" } satisfies Settings;// @ts-ignorefunction cheapest(prices: readonly number[]) {}
Put each line under what it does to TypeScript's checks.
Common mix-ups
- "More annotations make code safer." No. Inside your code they repeat what TypeScript already knows, and a wide one like
: stringcan throw away precision. Write types at the edges. - "
asconverts a value." No. It only changes what TypeScript believes. Nothing runs. - "
!checks for null." No. It tells TypeScript not to check. If the value is missing, the code goes on with it. - "
unknownis just a stricterany, so it doesn't matter which." It matters:anyturns checks off for everything made from the value, andunknownkeeps them on until you check. - "Optional properties are the easy way to model states." They allow every mix, including impossible ones. A union with a
statusallows only real states. - "A slow editor means you need fewer types." No. It usually means some types are built in a costly way. Measure, then use interfaces, names, and base types.
Practice exercises
Each exercise is TypeScript. Press Edit & run on the starter code to change it: the errors update as you type, and the console shows what it prints. Several starters have no errors at all. That's the point: find what the habit would catch.
Line 7 is marked. Fix it by removing something, not adding. What type does TypeScript give size then?
type Size = "small" | "large";function pour(size: Size) { console.log(`A ${size} tea`);} const size: string = "large";pour(size);type Size = "small" | "large";function pour(size: Size) { console.log(`A ${size} tea`);} const size = "large";// ^?pour(size);With no annotation, const size = "large" keeps its exact value, so the query shows const size: "large". That fits Size, the error is gone, and it prints A large tea.
Fix the error without using any or as. What does your fixed code print?
const saved: unknown = JSON.parse("5");console.log(saved + 1);const saved: unknown = JSON.parse("5");if (typeof saved === "number") { console.log(saved + 1);}Inside if (typeof saved === "number"), TypeScript knows saved is a number, so saved + 1 is allowed. The saved text is "5", which parses to the number 5, so it prints 6.
No errors here. What does the starter print? Then remove the ! and handle a missing price.
const prices = new Map([["tea", 3], ["cake", 4]]);const scone = prices.get("scone")!;console.log(scone * 2);const prices = new Map([["tea", 3], ["cake", 4]]);const scone = prices.get("scone");console.log(scone === undefined ? "No scones today" : scone * 2);The starter prints NaN: there are no scones, so scone is undefined, and the ! only hid that from TypeScript. The fix checks for undefined and prints No scones today.
What does the starter print? Change the type so that call becomes an error, then fix the call.
type Visitor = { loggedIn: boolean; name?: string }; function greet(visitor: Visitor) { return visitor.loggedIn ? `Hi, ${visitor.name}` : "Hi, guest";}console.log(greet({ loggedIn: true }));type Visitor = { loggedIn: false } | { loggedIn: true; name: string }; function greet(visitor: Visitor) { return visitor.loggedIn ? `Hi, ${visitor.name}` : "Hi, guest";}console.log(greet({ loggedIn: true, name: "Asha" }));The starter prints Hi, undefined. With the union, greet({ loggedIn: true }) is an error, because a logged-in visitor needs a name. Passing name: "Asha" prints Hi, Asha.
This prints undefined, with no error. Which payment method does the switch forget? Make TypeScript tell you, then handle it.
type Payment = | { method: "card"; last4: string } | { method: "cash" } | { method: "voucher"; code: string }; function receipt(payment: Payment) { switch (payment.method) { case "card": return `Card ending ${payment.last4}`; case "cash": return "Paid in cash"; }}console.log(receipt({ method: "voucher", code: "TEA10" }));type Payment = | { method: "card"; last4: string } | { method: "cash" } | { method: "voucher"; code: string }; function receipt(payment: Payment): string { switch (payment.method) { case "card": return `Card ending ${payment.last4}`; case "cash": return "Paid in cash"; case "voucher": return `Voucher ${payment.code}`; default: { const unhandled: never = payment; return unhandled; } }}console.log(receipt({ method: "voucher", code: "TEA10" }));The switch forgets "voucher", so the starter prints undefined. A never line in default would report Type '{ method: "voucher"; code: string; }' is not assignable to type 'never'. With a case "voucher", it prints Voucher TEA10.
What does the starter print? Replace the as Order with a type guard.
type Order = { id: number; total: number }; const order = JSON.parse('{"id":7,"total":"12"}') as Order;console.log(order.total + 3);type Order = { id: number; total: number }; function isOrder(data: unknown): data is Order { return ( typeof data === "object" && data !== null && "id" in data && typeof data.id === "number" && "total" in data && typeof data.total === "number" );} const data: unknown = JSON.parse('{"id":7,"total":"12"}');console.log(isOrder(data) ? data.total + 3 : "Not an order");The starter prints 123: as Order didn't check anything, and text plus 3 joins. isOrder checks both properties' types, so this reply prints Not an order instead.
Check your understanding
The quiz shows its code without the checker, so the error list doesn't give the answers away. For each question, ask what TypeScript checks, and what really happens when the code runs.
Question 1 of 7In a small shop app, which of these needs a type written on it?
Choose an answer to see the explanation.
Question 2 of 7What does
as Productdo when the code runs?Choose an answer to see the explanation.
Question 3 of 7What does this stock code print, and does TypeScript report anything?
Read the code, then predictconst stock = new Map([["tea", 5]]); const cakes = stock.get("cake")!; console.log(cakes + 1);Choose an answer to see the explanation.
Question 4 of 7Does TypeScript accept this request object?
Read the code, then predicttype ProductsRequest = { loading: boolean; error?: string; products?: string[] }; const request: ProductsRequest = { loading: true, error: "Timed out", products: ["Tea"] };Choose an answer to see the explanation.
Question 5 of 7Why give the result of
JSON.parsethe typeunknownrather thanany?Choose an answer to see the explanation.
Question 6 of 7You want TypeScript to check a settings object against
Settingsand keepthemeas the exact type"dark". What do you write?Choose an answer to see the explanation.
Question 7 of 7The editor is slow on a type built from many
&intersections. What does the TypeScript team suggest?Choose an answer to see the explanation.
Key takeaways
- Let TypeScript work out types inside your code. Write them on parameters, exported return types, and outside data.
- Use
unknownfor values you haven't checked, and narrow them.anyswitches checking off. asand!change what TypeScript believes, not the value. Prefer a check:if,??,?., or a type guard.- Model states as a union with a
status, and end each switch with anevercheck. - Keep types precise:
satisfiesfor config,readonlyfor inputs you mustn't change, andas constobjects instead of enums. - Keep
stricton, and check outside data once, where it comes in. - For speed, measure first, then prefer interfaces that extend, named types, and base types over huge unions.
Remember the one-liner.
Write types at the edges, check values instead of silencing errors, and make the types say exactly what can happen.
Coming next: Inside the compiler. It follows your code through TypeScript's scanner, parser, binder, checker, and emitter, and shows what each one does.