#Union types
A union type A | B says "this value is one of these, and right now I don't know which". It's easiest to think of types as sets of values: string is the set of every string, number the set of every number, and string | number is the bigger set that holds both. Unions exist because real JavaScript values are often one of several things: an ID that might be a string or a number, a parameter that accepts a selector or an element, an API result that is either data or an error, or simply User | null for "might be missing".
The price of that flexibility: while you hold a union, TypeScript only lets you do what is safe for every member. If id might be a number, id.toUpperCase() could crash, so it's refused. To use something member-specific you narrow: you write an ordinary runtime check (typeof, ===, instanceof, in …), and the compiler follows your ifs, returns and switches and works out a more precise type for the variable in each branch. This is called control flow analysis. A variable has one declared type, but at every point in the code it also has a narrowed type: whatever is left after the checks that must have passed to get there.
A mental model: a union is a sealed envelope that holds one of several things. Outside the if, you can only do what works for anything that might be inside. The check is you opening the envelope; inside each branch you know exactly what you're holding.
The example from my notes — I added toUpperCase() and toFixed() so each branch uses something only its member has:
function printId(id: string | number): void {
if (typeof id === "string") {
console.log(`ID is a string: ${id.toUpperCase()}`);
} else {
console.log(`ID is a number: ${id.toFixed(2)}`);
}
}
printId("abc-123"); // ID is a string: ABC-123
printId(42); // ID is a number: 42.00- At the top of the function,
idhas its declared typestring | number. The only members you could use here are the ones both types share, liketoString()andvalueOf(). typeof id === "string"is a plain JavaScript check — it survives compilation unchanged and runs at runtime. TypeScript recognises the pattern as a type guard, so inside theifblock it treatsidasstring, andid.toUpperCase()compiles.- In the
elsebranch the checker subtracts what was ruled out:string | numberminusstringleavesnumber. That's whyid.toFixed(2)compiles there without any extra check. - At runtime,
printId("abc-123")takes the first branch and logsID is a string: ABC-123;printId(42)takes theelseand logsID is a number: 42.00. - Once the
if/elseis over, the two branches join again andidis back tostring | number. Narrowing doesn't change the variable's type permanently — it's a fact about one place in the code.
Here is what the compiler stops you doing without a check:
function shout(id: string | number) {
console.log(id.toString()); // fine: both string and number have toString()
return id.toUpperCase(); // ❌ Property 'toUpperCase' does not exist on type 'string | number'.
}id.toString()compiles becausetoStringexists onstringand onnumber. Whateveridturns out to be at runtime, the call works.id.toUpperCase()fails with TS2339:Property 'toUpperCase' does not exist on type 'string | number'.The second line of the error,Property 'toUpperCase' does not exist on type 'number'., names the member that's the problem.- The rule: on a union you can only use the members that every member of the union has. The compiler isn't being fussy — if someone called
shout(42),(42).toUpperCase()really would throw aTypeError. - With objects this rule is the source of the classic "unions have fewer properties" surprise — see Unions vs intersections.
Narrowing doesn't need an if/else pair. The most common shape in real code is a guard clause that returns early, after which only the remaining types are left:
function initials(name: string | null | undefined): string {
if (name == null) return "?";
// from here on, name is string
return name
.split(" ")
.map((part) => part[0])
.join("");
}
console.log(initials("Ada Lovelace")); // AL
console.log(initials(undefined)); // ?namestarts asstring | null | undefined. Callingname.splithere would fail with "'name' is possibly 'null'" (see strict and strictNullChecks).name == nulluses loose equality on purpose:== nullis true for bothnullandundefined, and TypeScript knows that, so one check removes both.- If the check is true the function returns. The checker reasons: "the only way to reach the next line is that
name == nullwas false", so after thereturn,nameis narrowed tostring. initials("Ada Lovelace")splits into["Ada", "Lovelace"], maps to["A", "L"]and joins to"AL".initials(undefined)hits the guard and returns"?".
The checks TypeScript understands as narrowing: typeof, instanceof, in, equality (===, !==, == null), truthiness (if (x)), Array.isArray, assignments, and your own custom type guards and assertion functions. The rest of this chapter takes them one at a time.
Where unions show up in real code:
- Optional values —
User | null,string | undefined. WithstrictNullCheckson, this is how "might be missing" is written. - A fixed set of options —
"asc" | "desc","GET" | "POST". Literal unions are often used instead of enums (see Enums vs literal unions). - Flexible inputs —
string | string[],Date | number. Normalise them at the top of the function and work with one type after that. - States and results — loading/success/error. These work best as discriminated unions.
When not to use one: if a function's output type depends on its input type, returning a union forces every caller to narrow a result you already knew. parse(x: string | number): string | number loses the link between input and output; function overloads or a generic keep it.