Union types
Let a value be text or a number with a union type like string | number, use it safely with a typeof check, and type parameters that accept either one.
- 01Write a unionUse
A | Bto say a value can be one of several types, and read what TypeScript lets in. - 02Use a union safelyKnow which operations work on every member, and check the type with
typeoforinbefore using the rest. - 03Type real inputsAccept text or a number in a function parameter, and read union types TypeScript works out for results and lists.
One value, more than one type
A shop page shows an order. Older orders have a number for an ID, like 42. Newer ones arrive from the page address as text, like "A-42". In plain JavaScript, one variable can hold either. TypeScript isn't so sure:
let orderId = 42;// ^?orderId = "A-42";Line 1 stores 42. Line 2 is a question for TypeScript: // ^? asks "what type is the name my ^ points at?", and the page writes the answer after it: let orderId: number. You didn't write a type, so TypeScript worked one out from the first value. Line 3 stores text, and TypeScript marks it, on purpose here: Type 'string' is not assignable to type 'number'. In plain words: "this variable holds numbers, and you're putting text in it".
You want to say "text or a number". That's what a union type is. You write the types with a | between them, and you read the | as "or":
let orderId: string | number = 42;// ^?orderId = "A-42";console.log(orderId);orderId = true;Line 1 says orderId is a string | number: text or a number. It starts as 42, and the query on line 2 shows the type you wrote. Line 3 stores "A-42", and this time there's no error. Line 4 prints A-42. Line 5 tries true, which is a boolean (a true-or-false value). TypeScript marks it, on purpose: Type 'boolean' is not assignable to type 'string | number'. A boolean is neither of the two types the union lists. Each type in a union is called a member, so this union has two members.
Now try it yourself. Type a value for line 1 and watch the list under the code as you type.
let orderId: string | number = true;console.log(orderId);true1 error: line 1prints truetrue is a boolean, and the union only lets in text or numbers. TypeScript says Type 'boolean' is not assignable to type 'string | number'.
42 and "A-42" have no errors, because each one matches a member. true, null, and [42] each get an error, because none of them is text or a number. An empty box gets Expression expected, because line 1 needs a value.
orderId takes text or a number. "A-42" and 42 both fit. true is a boolean, so TypeScript keeps it out.A small cafe has a sign by the till: "cash or card". You can pay with either, and the cashier takes it. A voucher is turned away, because it isn't on the sign. The cashier also knows that only cash gets change, so before counting change, they look at what you handed over.
- In real life: The sign says: cash or card
- In TypeScript:
payment: string | number - In real life: You can pay with either one
- In TypeScript: Any value of either member type is allowed
- In real life: A voucher is turned away
- In TypeScript:
truegets an error: it isn't a member - In real life: To give change, the cashier first checks it's cash
- In TypeScript: To use a string method, you first check it's a string
A union type says a value can be one of several types. You write it with |, which reads "or": string | number. TypeScript lets you store a value of any member type, and it lets you use the value only in ways that work for every member, until you check which one you have.
Using a value that could be either
Storing a value in a union is easy: give it any member type. Using the value is where TypeScript gets careful. Here is a function that shows an order ID in capital letters:
function showOrderId(orderId: string | number) { console.log(orderId.toUpperCase());} showOrderId("a-42");Line 1 says orderId is text or a number. Line 2 calls toUpperCase(), a method that only text has. Line 5 calls the function with "a-42", which works and prints A-42. But TypeScript marks line 2 on purpose, with two lines of message:
Property 'toUpperCase' does not exist on type 'string | number'means "you can't be sure this value has atoUpperCase". A property is anything you read with a dot, and methods count.Property 'toUpperCase' does not exist on type 'number'names the member that lacks it. If someone callsshowOrderId(42), the code throws aTypeErrorand stops.
The rule is simple: with a union, you can only do what every member can do. Try different things on line 2. Each button swaps that one line; the code then calls the function with text and with a number.
function showOrderId(orderId: string | number) { console.log(orderId.toUpperCase());} showOrderId("a-42");showOrderId(42);1 error: line 2A-42TypeError: orderId.toUpperCase is not a functionOnly strings have toUpperCase. TypeScript says Property 'toUpperCase' does not exist on type 'string | number', and its second line names the member that lacks it: number. When it runs, the first call prints A-42, and the second, with 42, throws a TypeError.
toUpperCase() and toFixed(1) each belong to one member, so each gets an error, and each throws for the other member. .length gets an error too; plain JavaScript would quietly print undefined. + 1 gets an error because it adds to a number but joins to text. toString() and the template literal work for both, so there are no errors. The table below sums it up. Read each row as: this code, is it allowed on a string | number, and why.
| What you write | Allowed? | Why |
|---|---|---|
orderId.toString() | Yes. | Strings and numbers both have toString. |
String(orderId) | Yes. | String turns any value into text, so it works on both. |
orderId.toUpperCase() | No, until you check. | Only strings have it. A number would throw a TypeError. |
orderId.toFixed(1) | No, until you check. | Only numbers have it. Text would throw a TypeError. |
orderId + 1 | No. | It adds a number but joins text, so TypeScript won't guess which you meant. |
orderId.toUpperCase() inside if (typeof orderId === "string") | Yes. | Inside the check, TypeScript knows orderId is a string. |
To use a string method, you first check that you have a string. JavaScript's typeof operator gives the kind of a value as text, like "string" or "number" (the Checking types lesson covers it). TypeScript reads your typeof check:
function formatOrderId(orderId: string | number) { if (typeof orderId === "string") { return orderId.toUpperCase(); // ^? } return "#" + orderId; // ^?} console.log(formatOrderId("a-42"));console.log(formatOrderId(42));Line 2 asks whether orderId is text. Inside that if, the query on line 4 shows (parameter) orderId: string: TypeScript knows it's text there, so line 3 may call toUpperCase(). Line 3 also leaves the function with return, so line 6 runs only for numbers, and the query on line 7 shows (parameter) orderId: number. Line 10 prints A-42 and line 11 prints #42.
typeof check on line 2 splits the union. On the "yes" path, orderId is a string. On the other path, only number is left.Turning a wide type into a more exact one with a check like this is called narrowing. TypeScript understands many checks, and the Narrowing lessons in the next stage, starting with Narrowing with typeof & truthiness, cover them one by one. For now, typeof is all you need.
Unions of object types
A union can join object types too. A search box asks a server for products. The server sends one of two replies: the products it found, or a message saying why it failed. Each reply is an object with its own properties:
type Found = { status: number; results: string[] };type Failed = { status: number; message: string }; function showReply(reply: Found | Failed) { console.log(reply.status); console.log(reply.results);} showReply({ status: 404, message: "No products found" });Lines 1 and 2 give names to two object types with type. type Found = … means "from now on, Found stands for this object type" (the Type aliases & interfaces lesson covers this). A Found reply has a status and a list of results. A Failed reply has a status and a message. Line 4 says reply is either one.
Line 5 reads reply.status, and that's fine: both replies have it. Line 6 reads reply.results, and TypeScript marks it on purpose: Property 'results' does not exist on type 'Found | Failed', and on its second line, Property 'results' does not exist on type 'Failed'. Line 9 shows why: a failed reply has no results, so line 5 prints 404 and line 6 prints undefined.
Found | Failed reply could be either cart. Both carts hold status, so you can read it. Only one holds results, so TypeScript wants a check first.It's the same rule as before. A Found | Failed value could be either object, so you can read only the properties both have. To read the others, check which object you have. JavaScript's in operator asks whether an object has a property (Object basics covers it), and TypeScript reads that check too (the in & instanceof narrowing lesson goes further):
type Found = { status: number; results: string[] };type Failed = { status: number; message: string }; function showReply(reply: Found | Failed) { if ("results" in reply) { console.log(reply.results.join(", ")); // ^? } else { console.log(reply.message); // ^? }} showReply({ status: 200, results: ["Tea", "Cake"] });showReply({ status: 404, message: "No products found" });Line 5 asks whether reply has a results property. Only Found has one, so inside the if, the query on line 7 shows (parameter) reply: Found, and line 6 may read the results. In the else, only Failed is left: the query on line 10 shows (parameter) reply: Failed, and line 9 reads the message. Line 14 prints Tea, Cake, and line 15 prints No products found.
Unions in parameters, results, and lists
Unions show up most in function parameters, where you want to accept more than one kind of input. A price might come from the server as a number, or from a form as text:
function formatPrice(price: string | number) { return `$${Number(price).toFixed(2)}`;} console.log(formatPrice(4));console.log(formatPrice("4.5"));console.log(formatPrice(true));Line 1 says formatPrice takes a price that is text or a number. Line 2 turns it into a number with Number(price), which works for both, and writes it with two decimals. Line 5 passes 4 and prints $4.00. Line 6 passes "4.5" and prints $4.50. Line 7 passes true, and TypeScript marks it on purpose: Argument of type 'boolean' is not assignable to parameter of type 'string | number'. An argument is a value you pass in a call, and a parameter is the name that receives it. Plain JavaScript would print $1.00, because Number(true) is 1.
TypeScript also gives you unions, when a value really could be one of two things. You met one in the Arrays & tuples lesson: find gives back a matching item, or undefined when nothing matches.
const prices = [3, 4, 5];const firstBig = prices.find((price) => price > 3);// ^?console.log(firstBig);Line 1 makes a list of prices. Line 2 looks for the first price above 3. The query on line 3 shows const firstBig: number | undefined: a number if one matches, undefined if none does. Line 4 prints 4. The null, undefined & strictNullChecks lesson shows how to handle the undefined part.
Lists can hold a union too. When a list mixes text and numbers, TypeScript works out a union for its items:
const sizes = [8, "M", 10];// ^?sizes.push("L");sizes.push(true);Line 1 makes a list of clothing sizes: 8, "M", and 10. The query on line 2 shows const sizes: (string | number)[], which means "a list where each item is text or a number". Line 3 adds "L", which is fine. Line 4 tries to add true, and TypeScript marks it on purpose: Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
The round brackets in (string | number)[] matter. Without them, [] applies to number alone. The table below compares three types that look alike. Read each row as: this type, what it means, and whether it can hold the list [8, "M"].
| The type | What it means | Can it hold [8, "M"]? |
|---|---|---|
(string | number)[] | A list where each item can be text or a number. | Yes. |
string[] | number[] | A list of only text, or a list of only numbers. | No. It mixes the two in one list. |
string | number[] | One piece of text, or a list of numbers. | No. Without round brackets, [] applies to number alone. |
When a union gets long, you can put each member on its own line. A | is allowed before the first member, so every line starts the same way:
function setPageSize( size: | number | string,) { console.log(`Showing ${size} items`);} setPageSize(20);setPageSize("20");Lines 2 to 4 say size is a number or a string, exactly like number | string on one line. Lines 9 and 10 both print Showing 20 items.
A search box that finds by id or name
A shop's search box lets people find a product by its number or by its name. One function handles both, with a union parameter and a typeof check:
const products = [ { id: 7, name: "Tea" }, { id: 8, name: "Cake" },]; function findProduct(query: string | number) { if (typeof query === "number") { return products.find((product) => product.id === query); } return products.find((product) => product.name.toLowerCase() === query.toLowerCase());} console.log(findProduct(8)?.name);console.log(findProduct("tea")?.name);Lines 1 to 4 make two products. Line 6 says query is text or a number. Line 7 checks for a number; inside, line 8 compares it with each product's id. Line 10 runs only for text, so query.toLowerCase() is allowed there, and it compares names without caring about capital letters. Line 13 finds product 8 and prints Cake. Line 14 finds "tea" and prints Tea. The ?. reads name only if a product was found (Nullish and optional chaining covers it).
Where does the union come from? A text box always gives text. The page can turn digits into a number and keep anything else as text, and TypeScript works out the union by itself:
const searchBox = document.createElement("input");searchBox.value = "8"; const typed = searchBox.value;const query = Number.isNaN(Number(typed)) ? typed : Number(typed);// ^?console.log(query);Lines 1 and 2 make a text box and put "8" in it, as if someone typed 8. (The Form elements lesson covers inputs.) Line 4 reads the text. Line 5 asks whether Number(typed) is NaN ("not a number"). If it is, query keeps the text; if not, it gets the number. The query on line 6 shows const query: string | number, because either could happen. Line 7 prints 8, and that query is ready to pass to findProduct.
let id: string | number = "A-42";let id: string | number = false;function show(id: string | number) { return id.toString(); }function show(id: string | number) { return id.length; }function show(id: string | number) { return id + 1; }function show(id: string | number) { return typeof id === "string" ? id.toUpperCase() : id; }const sizes: (string | number)[] = [8, "M"];const sizes: string[] | number[] = [8, "M"];
Sort each line by what TypeScript says about it. Each line is checked on its own.
Common mix-ups
The order of a union's members doesn't matter. TypeScript may even show them in a different order from yours:
let price: number | string = 4;// ^?Line 1 says number | string. The query on line 2 shows let price: string | number. It's the same type: both say "text or a number".
- "
string | numbermeans the value is both." No. It's one or the other at any moment. The|reads "or". - "If a union includes
string, I can use string methods." No. You can use only what every member has, until a check liketypeof orderId === "string"narrows it. - "A union converts my value." No. Types are removed before the code runs.
formatPricestill needsNumber(price)to turn text into a number. - "
string | number[]is a list of text or numbers." No. That's one piece of text, or a list of numbers. A list of either is(string | number)[], with brackets. - "With a union of objects, I can read any property from either one." No. Only properties every object has, like
status. Check withinbefore reading the rest. - "
number | stringandstring | numberare different types." No. The order doesn't matter, as the query above shows.
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. Try a guess first, then use the hints.
A discount can be a number or text like "10%". Which line will TypeScript mark?
let discount: string | number = 10;
discount = "10%";
discount = false;
discount = 5;let discount: string | number = 10;discount = "10%";discount = false; // line 3: a boolean is neither text nor a numberdiscount = 5;"10%" is text and 5 is a number, so both fit. Line 3 stores false, a boolean, so TypeScript reports Type 'boolean' is not assignable to type 'string | number'.
No types are written here. What type does TypeScript give sizes? Guess first, then point at sizes or add a // ^? line under it.
const sizes = ["S", 10, "L"];const sizes = ["S", 10, "L"];// ^?The items are "S", 10, and "L", so each item is text or a number. TypeScript writes that as (string | number)[], and the query on line 2 shows const sizes: (string | number)[].
A product ID can be a number or text like "tea-7". Fix line 1 so the error on line 3 goes away.
let productId: number = 7;console.log(productId);productId = "tea-7";console.log(productId);let productId: string | number = 7;console.log(productId);productId = "tea-7";console.log(productId);With string | number on line 1, both 7 and "tea-7" are allowed. The error is gone, and the code prints 7, then tea-7.
shout takes a label as text or a number. Fix line 2 so it works for both. What does shout(7) print then?
function shout(label: string | number) { return label.toUpperCase();} console.log(shout("sale"));console.log(shout(7));function shout(label: string | number) { if (typeof label === "string") { return label.toUpperCase(); } return String(label);} console.log(shout("sale"));console.log(shout(7));Inside typeof label === "string", TypeScript knows label is text, so toUpperCase is allowed. Numbers skip the if and become text with String(label), so shout(7) prints 7 instead of throwing.
A payment is a card or cash. TypeScript marks line 5. What does the starter's second call print when it runs? Then fix the function so each payment gets the right sentence.
type Card = { method: string; last4: string };type Cash = { method: string; amount: number }; function describe(payment: Card | Cash) { return `Paid by ${payment.method}, card ending ${payment.last4}`;} console.log(describe({ method: "card", last4: "4242" }));console.log(describe({ method: "cash", amount: 20 }));type Card = { method: string; last4: string };type Cash = { method: string; amount: number }; function describe(payment: Card | Cash) { if ("last4" in payment) { return `Paid by ${payment.method}, card ending ${payment.last4}`; } return `Paid by ${payment.method}: $${payment.amount}`;} console.log(describe({ method: "card", last4: "4242" }));console.log(describe({ method: "cash", amount: 20 }));The starter prints Paid by cash, card ending undefined, because cash has no last4. Inside "last4" in payment, TypeScript knows it's a Card; after it, only Cash is left, so payment.amount is allowed.
A plus button passes a number, and a text box passes text. Fix addToCart so both work. What does your fixed code print?
let cartCount = 0; function addToCart(quantity: number) { cartCount += quantity;} addToCart(2);addToCart("3");console.log(cartCount);let cartCount = 0; function addToCart(quantity: string | number) { cartCount += Number(quantity);} addToCart(2);addToCart("3");console.log(cartCount);quantity: string | number accepts both calls, and Number(quantity) makes "3" the number 3, so the count is 2 + 3 = 5. The starter, run anyway, prints 23, because 2 + "3" joins text.
Check your understanding
For each question, ask which member types a value could have, and whether the code works for every one of them. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7What does the type
string | numbermean?Choose an answer to see the explanation.
Question 2 of 7Which line does TypeScript flag in these
setSizecalls?Read the code, then predictfunction setSize(size: string | number) { console.log(size); } setSize(12); setSize("large"); setSize(["large"]);Choose an answer to see the explanation.
Question 3 of 7What does TypeScript say about
size.toFixed(1)here?Read the code, then predictfunction showSize(size: string | number) { return size.toFixed(1); }Choose an answer to see the explanation.
Question 4 of 7In this
showSize, what type doessizehave on line 3?Read the code, then predictfunction showSize(size: string | number) { if (typeof size === "number") { return size.toFixed(1); } return size; }Choose an answer to see the explanation.
Question 5 of 7Which type is a list where each item can be text or a number?
Choose an answer to see the explanation.
Question 6 of 7This code has a type error. What does
addOne("4")print when it runs anyway?Read the code, then predictfunction addOne(price: string | number) { return price + 1; } console.log(addOne("4"));Choose an answer to see the explanation.
Question 7 of 7Which property can
logReplyread fromreplywithout a check first?Read the code, then predicttype Found = { status: number; results: string[] }; type Failed = { status: number; message: string }; function logReply(reply: Found | Failed) { console.log(reply); }Choose an answer to see the explanation.
Key takeaways
- A union type says a value can be one of several types:
string | numberis text or a number. - Any value of a member type fits. Anything else, like
trueornull, gets an error. - You can use only what every member can do, like
toString(), until you check. - A
typeofcheck, orinfor objects, narrows the union to one member inside the check. - Union parameters let one function accept several kinds of input; you still convert the value yourself.
- TypeScript works out unions too:
number | undefinedfromfind,(string | number)[]for a mixed list.
Remember the one-liner.
A union means "one of these": store any of them, use what they share, and check before using the rest.
Coming next: Literal types. A type can be one exact value, like "small", and a union of those makes a list of allowed options.