Skip to content

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.

TypeScript
function printId(id: string | number): void {
  console.log("ID:", id);
}

printId(1024);
printId("A-204");
Output
ID: 1024
ID: A-204

Both 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:

TypeScript
function formatId(id: string | number): string {
  return id.toUpperCase();
}
Output
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:

TypeScript
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));
Output
A-204
42

Inside 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:

TypeScript
let direction: "up" | "down" | "left" | "right";
direction = "up";
console.log(direction);
Output
up

direction can only ever hold one of those four exact strings - anything else, even a perfectly reasonable-looking one, is rejected before the program runs:

TypeScript
let direction: "up" | "down";
direction = "left";
Output
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:

TypeScript
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));
Output
20
5

Calling 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:

TypeScript
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);
}
Output
red
green
yellow
red

type 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:

TypeScript
function nextDirection(current: "up" | "down"): "up" | "down" {
  if (current === "left") {
    return "up";
  }
  return "down";
}
Output
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:

TypeScript
let a = "up";
a = "sideways";
console.log(a);
Output
sideways

That 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.