Made by VeraStack Labs
Contents
One slip, two kinds
A union says 'one of these'. Narrowing finds out which.
Picture a chai stall at a busy station. Orders arrive on paper slips. Some say "chai, 2 sugars". Others say "coffee, strong". The cook looks at the first word, and then knows what else to expect.
If it says chai, there is a sugar count. If it says coffee, there is a strength. Nobody asks a chai order how strong it wants to be. That would be a silly question.
Real programs are full of slips like this. An ID that is sometimes text ("b2") and sometimes a number (14). A payment that is UPI, card or cash. A result that is either a success or an error.
TypeScript has a word for "one of these": a union type. You write it with a bar, as in string | number, and read the bar as "or". The checker then makes you look at the slip before you use it. Looking at the slip, and finding out which kind it is, is called narrowing. Once you have narrowed, TypeScript knows which kind you are holding and lets you use what belongs to it.
Remember: a union is "one of these". Narrowing is reading the slip to find out which one.
Slips nobody checks
JavaScript lets you act on the wrong kind.
Here is a function that makes a label for a ticket. The ID might be text, like "b2", or a number, like 14.
function ticketLabel(id) { return "Ticket " + id.toUpperCase();}console.log(ticketLabel("b2"));console.log(ticketLabel(14));Ticket B2
TypeError: id.toUpperCase is not a functionThe first call is fine. The second crashes, because a number has no toUpperCase. A method is a function that belongs to a value, such as toUpperCase on text. Numbers don't have that one.
Now the chai stall. Every order has a kind. Chai has a sugar count and coffee has a strength.
function describeOrder(order) { return order.kind + ": " + order.sugar + " sugars";}console.log(describeOrder({ kind: "chai", sugar: 2 }));console.log(describeOrder({ kind: "coffee", strength: "strong" }));chai: 2 sugars
coffee: undefined sugarsThe function asked every order for its sugar. A coffee order has no sugar, so JavaScript says undefined and carries on. The customer reads "undefined sugars". Nobody warned us.
Remember: JavaScript will let you ask a slip a question it can't answer. You find out when it crashes, or when a customer reads the result.
Say what it could be
A union lists the options. The checker makes you pick.
Start with the ticket. The ID is string | number, which reads "text or a number". Watch what the checker says about toUpperCase.
function ticketLabel(id) { return "Ticket " + id.toUpperCase();}console.log(ticketLabel("b2"));console.log(ticketLabel(14));Tinted lines are the ones that differ
TypeScript says no, and the reason is fair. The method exists on text, but not on numbers, and the ID might be a number. With a union, you may only use what every option has in common. Anything else is refused until you check.
Here is the fix. Ask what kind of thing it is, using typeof. That is the same JavaScript word you already know. TypeScript reads the check and remembers the answer inside the if.
function ticketLabel(id: string | number): string { if (typeof id === "string") { return "Ticket " + id.toUpperCase(); } return "Ticket no. " + id;}console.log(ticketLabel("b2"));console.log(ticketLabel(14));Ticket B2
Ticket no. 14Inside the if, id is a string, so toUpperCase is allowed. After the if returns, the only thing left is a number, so TypeScript knows that too. No as, no guessing. You asked, and the checker listened.
Now the chai stall. We describe each order with a type of its own, and the order is one or the other.
function describeOrder(order) { return order.kind + ": " + order.sugar + " sugars";}console.log(describeOrder({ kind: "chai", sugar: 2 }));console.log(describeOrder({ kind: "coffee", strength: "strong" }));Tinted lines are the ones that differ
Look at the first line of each order type: kind: "chai". That is not "any text". It is the exact word "chai". A type can be one exact value, and that is called a literal type. Because every order carries its own exact kind, the checker can tell the options apart by it. The error names the problem: CoffeeOrder has no sugar.
Remember: with a union you may only use what all the options share. A check, like typeof, tells TypeScript which one you hold.
Four ways to check
typeof, in, equality, and a kind field.
Each of these is something you may already write in JavaScript. TypeScript simply watches you do it.
1. typeof. It works for the basic kinds: "string", "number", "boolean", "bigint", "symbol", "undefined", "function" and "object". It is perfect for "text or a list of text".
function toList(input: string | string[]): string[] { if (typeof input === "string") { return input.split(","); } return input;}console.log(toList("chai,coffee"));console.log(toList(["lassi"]));[ 'chai', 'coffee' ]
[ 'lassi' ]Careful with one oddity. In JavaScript typeof null is "object". This is an old quirk that was never fixed. Lesson 6 deals with null properly. To tell a list from other objects, Array.isArray(value) is the check. And value instanceof Date asks whether something was made by a certain class, and narrows the same way.
2. Equality. Comparing with === narrows too. It works nicely with a literal union, which is a union of exact words.
function brew(strength) { return strength === "strong" ? "Two shots" : "One shot";}console.log(brew("strong"));console.log(brew("medium"));Tinted lines are the ones that differ
type Strength = "mild" | "strong" says there are exactly two allowed words. JavaScript lets "medium" through and quietly gives one shot. TypeScript refuses it, and the editor offers the two allowed words as you type. This beats an if that checks for a typo at runtime. It also beats a plain string that accepts anything.
3. The in operator. Sometimes the options have no kind word, but they differ in what they have. The JavaScript in operator asks "does this object have a property with this name?". TypeScript uses the answer to narrow.
type Batter = { name: string; runs: number };type Bowler = { name: string; wickets: number };function summary(player: Batter | Bowler): string { if ("runs" in player) { return player.name + " scored " + player.runs; } return player.name + " took " + player.wickets + " wickets";}console.log(summary({ name: "Kohli", runs: 82 }));console.log(summary({ name: "Bumrah", wickets: 3 }));Kohli scored 82
Bumrah took 3 wicketsIf "runs" is in the player, it must be a Batter. Otherwise it is a Bowler.
4. A discriminated union. That is the grand name for the chai stall. A discriminated union is a union where every option has the same property with a different literal value, such as kind. That property is the discriminant, meaning "the thing that tells them apart". You check it with switch or if, and each branch gets exactly the right option.
type ChaiOrder = { kind: "chai"; sugar: number };type CoffeeOrder = { kind: "coffee"; strength: "mild" | "strong" };type Order = ChaiOrder | CoffeeOrder;function serve(order: Order): string { switch (order.kind) { case "chai": return "Chai with " + order.sugar + " sugars"; case "coffee": return order.strength + " coffee"; }}console.log(serve({ kind: "chai", sugar: 2 }));console.log(serve({ kind: "coffee", strength: "strong" }));Chai with 2 sugars
strong coffeeInside case "chai", order is a ChaiOrder, so order.sugar is allowed. Inside case "coffee", it is a CoffeeOrder. This is the pattern you will see most in real TypeScript. It replaces the long if chains that guess at fields.
Remember: typeof for basic kinds, === for exact words, in for "has this property", and a kind field when you design the types yourself.
When the checker shouts
Exhaustiveness, and three small traps.
Now the part the stall owner will love. Add a new kind of order: lassi. What happens to the code that serves orders? In JavaScript, nothing happens. The new kind falls through the switch and someone gets no lassi, and no error.
TypeScript can make the checker do the counting. The trick uses the type never, which means "a value that can't exist". Here is how it works. After you handle chai and coffee, the only option left is lassi. If you handle lassi too, nothing is left, so order has the type never. A function that asks for never will only accept a call when no option is missing.
First, the broken version. We added LassiOrder to the union and forgot to serve it.
type ChaiOrder = { kind: "chai"; sugar: number };type CoffeeOrder = { kind: "coffee"; strength: "mild" | "strong" };type LassiOrder = { kind: "lassi"; flavour: string };type Order = ChaiOrder | CoffeeOrder | LassiOrder;function assertNever(value: never): never { throw new Error("Unserved order: " + JSON.stringify(value));}function serve(order: Order): string { switch (order.kind) { case "chai": return "Chai with " + order.sugar + " sugars"; case "coffee": return order.strength + " coffee"; default: return assertNever(order); }}TypeScript says
error TS2345: Argument of type 'LassiOrder' is not assignable to parameter of type 'never'.
The error says LassiOrder can't go where never is wanted. That is the checker saying "you forgot one". Handle lassi, and the error goes away.
type ChaiOrder = { kind: "chai"; sugar: number };type CoffeeOrder = { kind: "coffee"; strength: "mild" | "strong" };type LassiOrder = { kind: "lassi"; flavour: string };type Order = ChaiOrder | CoffeeOrder | LassiOrder;function assertNever(value: never): never { throw new Error("Unserved order: " + JSON.stringify(value));}function serve(order: Order): string { switch (order.kind) { case "chai": return "Chai with " + order.sugar + " sugars"; case "coffee": return order.strength + " coffee"; case "lassi": return order.flavour + " lassi"; default: return assertNever(order); }}console.log(serve({ kind: "lassi", flavour: "Mango" }));Mango lassiThis is called an exhaustiveness check, meaning a check that you handled every option. If someone adds a fourth kind next year, every switch that forgot it lights up red. It also throws at runtime, just in case bad data slips past. Use this on any switch over a union.
A literal can widen. A variable that can change gets a wider type. A plain let strength = "strong" becomes string, because later you might put any text in it.
type Strength = "mild" | "strong";function brew(strength: Strength): string { return strength === "strong" ? "Two shots" : "One shot";}let strength = "strong";console.log(brew(strength));TypeScript says
error TS2345: Argument of type 'string' is not assignable to parameter of type 'Strength'.
Use const and the exact word stays. Or write the type yourself: let strength: Strength = "strong".
type Strength = "mild" | "strong";function brew(strength: Strength): string { return strength === "strong" ? "Two shots" : "One shot";}const strength = "strong";console.log(brew(strength));Two shotsA plain string kind loses everything. If you write kind: string instead of kind: "chai", the checker can't tell the options apart. A discriminant has to be a literal.
Narrowing is about this spot in the code. A check protects the lines it covers. If you change the variable afterwards, or leave and come back later through a callback, TypeScript may forget what it knew.
Remember: end a switch over a union with a never check, and use const or a type to keep exact words from widening.
Three slips, sorted
Text or number, which order, which payment.
Each problem starts with a JavaScript function. Open it in the Playground, type it, and let the checker help.
Problem 1
GST on text or a number
Add types to withGst, so it accepts a number or text, and always gives back a number. withGst("100") should give 118, the same as withGst(100).
Start here
function withGst(amount) { return amount + amount * 0.18;}console.log(withGst(100)); // 118console.log(withGst("100")); // "10018", because "100" is textStuck?
Show a solution
function withGst(amount: number | string): number { const rupees = typeof amount === "string" ? Number(amount) : amount; return rupees + rupees * 0.18;}withGst(100); // 118withGst("100"); // 118number | string says either kind is welcome. The typeof check turns the text into a number, so the arithmetic always happens on a number. The JavaScript version returned '10018' for the text '100', because + joins text instead of adding.
Problem 2
Bill the order
Describe the orders as a union and add types to bill. Chai costs 10. Coffee costs 20, or 30 if it is strong. The call with a lassi should be refused. Remove it.
Start here
function bill(order) { if (order.kind === "chai") { return 10; } return order.strength === "strong" ? 30 : 20;}console.log(bill({ kind: "chai", sugar: 2 })); // 10console.log(bill({ kind: "coffee", strength: "strong" })); // 30console.log(bill({ kind: "lassi" })); // 20, a lassi is billed as a mild coffeeStuck?
Show a solution
type ChaiOrder = { kind: "chai"; sugar: number };type CoffeeOrder = { kind: "coffee"; strength: "mild" | "strong" };type Order = ChaiOrder | CoffeeOrder;function bill(order: Order): number { if (order.kind === "chai") { return 10; } return order.strength === "strong" ? 30 : 20;}bill({ kind: "chai", sugar: 2 }); // 10bill({ kind: "coffee", strength: "strong" }); // 30With kind as a literal in each type, checking order.kind === 'chai' narrows the order. After the early return, TypeScript knows only a coffee order is left, so order.strength is allowed. The lassi call was billed as a mild coffee in JavaScript. Now it is refused.
Problem 3
One receipt per payment
Type receipt with a union, and end the switch with an assertNever check. The call with a wallet should be refused. Remove it.
Start here
function receipt(payment) { switch (payment.method) { case "upi": return "Paid by UPI to " + payment.id; case "card": return "Paid by card ending " + payment.last4; default: return "Paid in cash"; }}console.log(receipt({ method: "upi", id: "asha@upi" })); // "Paid by UPI to asha@upi"console.log(receipt({ method: "cash" })); // "Paid in cash"console.log(receipt({ method: "wallet" })); // "Paid in cash", which is wrongStuck?
Show a solution
type Payment = | { method: "upi"; id: string } | { method: "card"; last4: string } | { method: "cash" };function assertNever(value: never): never { throw new Error("Unhandled payment: " + JSON.stringify(value));}function receipt(payment: Payment): string { switch (payment.method) { case "upi": return "Paid by UPI to " + payment.id; case "card": return "Paid by card ending " + payment.last4; case "cash": return "Paid in cash"; default: return assertNever(payment); }}receipt({ method: "upi", id: "asha@upi" }); // "Paid by UPI to asha@upi"The union lists exactly three methods. Each case narrows the payment, so payment.id and payment.last4 are allowed only where they exist. The default branch is where payment has become never, and passing it to assertNever is the exhaustiveness check. The wallet call in the JavaScript was quietly receipted as cash.
Unions in the interview
Narrowing, discriminants, and never.
Unions come up in nearly every TypeScript interview. These are the usual ones.
- "What is a union type?" A type that can be one of several types, written
A | B. You may only use what all the members share, until you narrow. - "What is narrowing?" Making a type more specific using a check, such as
typeof,===,in,instanceoforArray.isArray. TypeScript follows the check through the code. - "What is a discriminated union?" A union where every member has a common property with a literal type, such as
kind. Checking that property narrows to one member. - "How do you check a
switchhandled every case?" Usenever. In thedefaultbranch, pass the value to a function that takesnever. If a case is missing, the code doesn't compile. - "What does
nevermean?" The type of a value that can't exist. A function that always throws returnsnever. It is also what is left after you've ruled out every option. - "What is a literal type?" A type that is one exact value, such as
"chai"or42. A union of them, like"mild" | "strong", works like a small enum. - "Why is
let x = "a"a string, butconst x = "a"is exactly "a"?" Aletcan change, so TypeScript widens it. Aconstcan't, so the exact word is kept. - "What is
typeof null?""object", a JavaScript quirk. It is why you check fornullseparately. - Say it simply. "A union is one of several types. I narrow it with a check, and a
kindfield makes that easy" is a good answer.
Updated 2026-10-17
Made by VeraStack Labs