TypeScript lesson 6 of 9
Union and Literal Types
How to say a value can be one of several types with the | operator, how a literal type narrows a type down to one exact value, and how typeof and if let you use a union value safely.
Published · Every example on this page was run before it was published.
Every type covered so far has described exactly one kind of value. Real programs often need to say "this could legitimately be more than one kind" - an ID might be typed in as text or looked up as a number, a setting might be one of exactly four named options. TypeScript has two closely related features for this: the union type, for "one of these types," and the literal type, for "exactly this one value."
Union Types
A union type is written with a pipe, |, between two or more types, and it means a value must match
at least one of them.
function printId(id: string | number): void {
console.log("ID:", id);
}
printId(1024);
printId("A-204");ID: 1024
ID: A-204Both calls are valid: 1024 matches number, and "A-204" matches string, and id's type,
string | number, accepts either.
Unions Restrict What You Can Do Without Checking First
Inside the function, TypeScript only allows operations that are valid for every type in the union - not just one of them. Calling a string-only method on a value that might be a number is rejected, even on a call where the actual value passed in happens to be a string:
function formatId(id: string | number): string {
return id.toUpperCase();
}main.ts(2,13): error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
Property 'toUpperCase' does not exist on type 'number'.toUpperCase exists on string, but not on number, and the compiler has to assume id might be
either, because that is exactly what its type says is allowed.
Narrowing with typeof
The fix is to check which member of the union you actually have before using type-specific behavior.
The typeof operator, tested inside an if, is enough for TypeScript to work this out on its own -
this is called narrowing, because the compiler narrows the value's type down within the branch
where the check has already passed:
function formatId(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase();
}
return id.toFixed(0);
}
console.log(formatId("a-204"));
console.log(formatId(42));A-204
42Inside the if block, TypeScript treats id as string alone, so .toUpperCase() is allowed there.
After the if block - on the final return - the only possibility left is number, so .toFixed(0)
is allowed there instead, with no further check needed.
Literal Types
A literal type is a type with exactly one legal value - a specific string, written in the type position instead of the value position. On its own a literal type is not very useful, but a union of several literal types describes a fixed, closed set of options:
let direction: "up" | "down" | "left" | "right";
direction = "up";
console.log(direction);updirection can only ever hold one of those four exact strings - anything else, even a perfectly
reasonable-looking one, is rejected before the program runs:
let direction: "up" | "down";
direction = "left";main.ts(2,1): error TS2322: Type '"left"' is not assignable to type '"up" | "down"'.Literal Unions as Function Parameters
This pattern shows up constantly as a parameter type, in place of a plain string that a comment would
otherwise have to explain:
function resize(mode: "grow" | "shrink", amount: number): number {
return mode === "grow" ? amount * 2 : amount / 2;
}
console.log(resize("grow", 10));
console.log(resize("shrink", 10));20
5Calling resize("expand", 10) would be rejected at compile time, the same way the direction example
above was - "expand" is simply not one of the two values mode is allowed to hold, and there is no
chance of that typo reaching the running program at all.
A Worked Example
Literal unions and equality narrowing work well together for representing a small set of named states:
type Light = "red" | "yellow" | "green";
function next(current: Light): Light {
if (current === "red") {
return "green";
}
if (current === "green") {
return "yellow";
}
return "red";
}
let light: Light = "red";
for (let i = 0; i < 4; i++) {
console.log(light);
light = next(light);
}red
green
yellow
redtype Light = ... names the union so it can be reused as a type in more than one place, exactly the way
interface names an object shape. Each call to next narrows current with a plain === comparison
rather than typeof - since every value in the union is already a specific string literal, comparing
directly against one of them is enough for TypeScript to know exactly which case it is in.
Common Mistakes
Comparing a union against a value that shares no overlap with any of its members. TypeScript checks equality comparisons between a literal union and another literal for at least the possibility of a match, and flags one that could never succeed:
function nextDirection(current: "up" | "down"): "up" | "down" {
if (current === "left") {
return "up";
}
return "down";
}main.ts(2,7): error TS2367: This comparison appears to be unintentional because the types '"up" | "down"' and '"left"' have no overlap.current can only ever be "up" or "down", so asking whether it equals "left" can never be true -
which is almost always a typo rather than something intentional, and the compiler treats it as one.
Not realizing let and const infer literal unions differently. A const assigned a string
literal is inferred as that exact literal type, since it can never be reassigned to anything else. A
let assigned the same literal is inferred as the wider, general string type instead, because it
might later be reassigned to any string at all:
let a = "up";
a = "sideways";
console.log(a);sidewaysThat reassignment compiles without complaint, because a's inferred type is plain string, not the
literal "up". If you want a variable to be restricted to a fixed set of literals the way direction
was earlier in this lesson, write the union type explicitly - inference alone will not produce it for a
let.
Assuming every type-mismatch on a union looks the same. The toUpperCase example and the
"left"/"up" example above are both rejected by the compiler, but for different reasons: one is a
missing method on part of the union, reported as TS2339; the other is a comparison that can never
succeed, reported as TS2367. Reading the exact wording, not just noticing that an error occurred, is
what tells you which situation you are actually in.
Next Steps
The valid-palindrome practice problem works entirely with strings and booleans, but framing a
helper's return value as a small literal union ("match" | "mismatch", say) instead of a bare boolean
is a good way to practice this lesson while solving it.
In the TypeScript playground, declare your own literal union for a set
of options you invent - three or four short strings - and write a function that takes it as a
parameter. Try calling that function with a string that is not one of the options, and separately try
comparing the parameter against a value outside the union inside an if, to see both kinds of rejection
this lesson covered.
When you want a harder one
Interview-style problems graded against hidden tests — a big step up from the exercises. Come back to these when the ideas in this lesson feel comfortable rather than new.