Union types
A union type describes a value that is one of several types, written with | between the alternatives. A value fits a union if it matches at least one member, and a union is assignable to a single type only if every member fits. You narrow a union at runtime to work with the value as one specific member.
A union type describes a value that is one of several types. You write it with | between the alternatives, and it reads the way it sounds: a value of type string | number is either a string or a number, and nothing else.
type Id = string | number
Unions are how you model a value with more than one legitimate shape: an identifier that might be numeric or textual, a result that is either a value or an error, a setting that is one of a fixed set of strings. If you have used unions in TypeScript, Rust enums, or tagged variants in other languages, this is the same idea.
How a union simplifies
When the type system builds a union it tidies it up, so you always get the simplest equivalent form:
- A member that is
never— the empty type, a value that cannot exist — is dropped, since it contributes no possibilities. - A union of a single remaining member collapses to just that type.
- An empty union (every member removed) becomes
never.
type T = string | never | number // string | number
type U = never // never (nothing left)
What you can assign
Assignability runs in the direction you would expect, and it is worth stating both sides plainly because they are not symmetric.
Assigning into a union: a value is assignable to a union if it matches at least one member. A number fits string | number because it matches the number alternative.
Assigning out of a union: a union value is assignable to some target only if every member is. A string | number is not assignable to string, because the number case would not fit.
const a: string | number = 42 // ok: 42 matches a member
const b: string = (x: string | number) // error: the number case does not fit
Working with a union's value
A union tells you the value is one of its members, but not which one, so you narrow it to find out. An ordinary runtime check selects a member, and inside the branch where the check passes, the value has just that member's type:
function describe(id: string | number): string {
if (typeof id === "number") {
return "numeric id" // here id is a number
}
return "textual id" // here id is a string
}
The typeof guard narrows id to number inside the if branch and to string after it, and the checker tracks that narrowing. Calling a method on the narrowed value in a runnable body — id.toFixed(0) or id.toUpperCase() — is a separate matter: a property read on a narrowed primitive is not executable yet, so the example above returns plain values rather than calling methods on the narrowed union.
Each branch subtracts the cases it rules out, so by the final branch only the remaining member is left. If you exhaust every case, what remains is never, which is how the type system can tell you a switch has covered all possibilities. See type narrowing for the checks that do this.
Unions in type-level code
Operations that transform types generally apply across a union member by member and then re-simplify the result. This is why filtering utilities like Exclude and Extract work the way they do: they walk the members and keep or drop each one. When you write type-level code over a union, think of it as running once per member and collecting the results.
Why it works this way
A union is the honest way to say "this value has more than one valid shape" without reaching for a catch-all like unknown. The value stays fully typed — the type system knows the complete set of possibilities — so it can check that you have handled each one before you use it. The narrowing rules are what make that safe: you cannot treat a string | number as a string until you have shown, at runtime, that it is one.
Limitations
- You must handle every case to use the value. A union value is not usable as any single member until you narrow it. This is the point, not a gap.
- Assigning a union to a single type requires all members to fit. A
string | numberis not astring; expect an error rather than an implicit narrowing.