Union, Intersection and Literal Types
Combining types: unions for values that can be one of several types, literal types for exact values, and intersections that merge object types.
Union types
JavaScript functions often accept more than one kind of value: a
meter ID that may arrive as a string or a number, a setting that is
a number or null. A
union type describes exactly that.
Write the possible types separated by |
(read it as "or"):
type MeterId = string | number;
function formatId(id: MeterId): string {
return `meter ${id}`;
}
console.log(formatId("E-1001"));
console.log(formatId(1001));
meter E-1001 meter 1001
The name comes from the
union of sets: if a type is the set of values it allows,
string | number is the set of all strings together with
all numbers. A union can have as many members as you need, and
unions with null or undefined (number | null) are the standard way to say "may be missing" (see
null, undefined and Strict Null Checks).
Working with a union value
Accepting a union is easy; using it takes one more step. The
compiler only allows operations that are valid for
every member. toUpperCase exists on strings
but not on numbers, so it is refused:
function formatId(id: string | number): string {
return id.toUpperCase();
}
example.ts(2,13): error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
Methods that both members share are fine, such as
toString(). For everything else, check which member you
have. Inside an if (typeof id === "number") block,
TypeScript knows id is a number, and after it, that it
must be a string. This is called narrowing, and it has its own page:
Narrowing and Type Guards.
function formatId(id: string | number): string {
if (typeof id === "number") {
return `E-${id}`;
}
return id.toUpperCase();
}
console.log(formatId(1001));
console.log(formatId("e-1002"));
E-1001 E-1002
The same rule applies to unions of object types: you can read a
property only when every member has it. With
Outage | Reading, reading e.ticket fails
because a Reading has no ticket (TS2339), while a
property both types share is allowed. A union of objects that share
a "kind" property is the most useful pattern in TypeScript; it is
explained under
discriminated unions.
Literal types
A literal type is a type with
exactly one value: the type "day" allows only the
string "day", the type 2 only the number
2. On its own that is not very useful. Combined in a union, it is
one of TypeScript's most practical features, a type for "one of
these values":
type Tariff = "day" | "night";
function pricePerKWh(tariff: Tariff): number {
return tariff === "day" ? 0.32 : 0.25;
}
console.log(pricePerKWh("day"));
console.log(pricePerKWh("night"));
0.32 0.25
Every call site is now checked against the list, including capitalization. Editors also offer the allowed values as autocompletion:
type Tariff = "day" | "night";
function pricePerKWh(tariff: Tariff): number {
return tariff === "day" ? 0.32 : 0.25;
}
console.log(pricePerKWh("Day"));
example.ts(7,25): error TS2345: Argument of type '"Day"' is not assignable to parameter of type 'Tariff'.
Number and boolean literals work the same way. A ticket priority from 1 to 4 and a fixed set of statuses become precise types, and a value outside them is caught:
type Priority = 1 | 2 | 3 | 4;
type Status = "open" | "in-progress" | "resolved";
interface Ticket {
id: string;
priority: Priority;
status: Status;
}
const t: Ticket = { id: "INC-30412", priority: 2, status: "open" };
console.log(`${t.id} P${t.priority} ${t.status}`);
const urgent: Ticket = { id: "INC-30415", priority: 5, status: "open" };
example.ts(13,43): error TS2322: Type '5' is not assignable to type 'Priority'.
The compiler also uses literal types to spot comparisons that can never be true. If a status can only be one of three values, testing it against a fourth is almost certainly a bug, perhaps a value that was renamed:
type Status = "open" | "in-progress" | "resolved";
function isWaiting(s: Status): boolean {
return s === "pending";
}
example.ts(4,10): error TS2367: This comparison appears to be unintentional because the types 'Status' and '"pending"' have no overlap.
Type widening
Which type does TypeScript infer for "day"? It depends
on where the value is stored. A const can never change,
so it keeps the literal type "day". A
let variable may be reassigned later, so its type is
widened to string. This
type widening is usually what
you want, but it explains a common surprise:
let tariff = "day";
const fixed = "day";
type Tariff = "day" | "night";
function pricePerKWh(t: Tariff): number {
return t === "day" ? 0.32 : 0.25;
}
console.log(pricePerKWh(fixed));
console.log(pricePerKWh(tariff));
example.ts(10,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Tariff'.
The call with fixed (line 9) is accepted; the one with
tariff (line 10) is not, because tariff is
a string and could hold any text by then. Properties of
an object literal widen too, even inside a const
object, because properties can be reassigned:
type Tariff = "day" | "night";
function pricePerKWh(t: Tariff): number {
return t === "day" ? 0.32 : 0.25;
}
const contract = { customer: "C-20417", tariff: "night" };
console.log(pricePerKWh(contract.tariff));
example.ts(7,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Tariff'.
There are two clean fixes. Annotate the object with a type that uses
the literal union, so the compiler knows what
tariff is meant to be. Or add as const,
which tells the compiler to keep every value as its literal type and
make the properties readonly (more on
Enums, const Assertions and satisfies):
type Tariff = "day" | "night";
interface Contract {
customer: string;
tariff: Tariff;
}
function pricePerKWh(t: Tariff): number {
return t === "day" ? 0.32 : 0.25;
}
const contract: Contract = { customer: "C-20417", tariff: "night" };
console.log(pricePerKWh(contract.tariff));
const other = { customer: "C-20418", tariff: "day" } as const;
console.log(pricePerKWh(other.tariff));
0.25 0.32
Contextual typing
Inference also works in the other direction. With
contextual typing, an
expression gets its type from the place where it is used. The
classic case is a
callback:
readings.map(...) knows it is called on a
number[], so the arrow function's first parameter is a
number, without any annotation. The same happens when
you assign a function to a variable whose function type is already
declared:
const readings = [4.2, 3.9, 5.1];
const labels = readings.map((kWh, i) => `#${i + 1}: ${kWh.toFixed(1)} kWh`);
console.log(labels);
const handler: (ticketId: string, minutes: number) => string = (id, min) =>
`${id} open for ${min} min`;
console.log(handler("INC-30412", 45));
[ '#1: 4.2 kWh', '#2: 3.9 kWh', '#3: 5.1 kWh' ] INC-30412 open for 45 min
The inferred parameter types are fully checked. Treating a reading
as a string inside the callback is caught, even though you never
wrote number anywhere:
const readings = [4.2, 3.9, 5.1];
readings.forEach((kWh) => console.log(kWh.toUpperCase()));
example.ts(2,43): error TS2339: Property 'toUpperCase' does not exist on type 'number'.
Without a context, there is nothing to infer from. The same arrow
function written on its own needs parameter annotations, or strict
mode reports an implicit any (see
Basic Types and Type Annotations):
const handler = (id, min) => `${id} open for ${min} min`;
example.ts(1,18): error TS7006: Parameter 'id' implicitly has an 'any' type.
Contextual typing also decides literal types: in
const c: Contract = { tariff: "night" } above, the
annotation is the context that keeps "night" a literal
instead of widening it to string.
Intersection types
An intersection type,
written A & B (read "and"), describes values that
are of type A and of type B at
the same time. For object types, that means the value must have all
properties of both, so an intersection is a way to combine shapes:
interface Contact {
name: string;
email: string;
}
interface Address {
street: string;
city: string;
}
type Customer = Contact & Address & { id: string };
const c: Customer = {
id: "C-20417",
name: "Amara Okafor",
email: "amara@example.com",
street: "Oudegracht 12",
city: "Utrecht",
};
console.log(`${c.name}, ${c.street}, ${c.city}`);
Amara Okafor, Oudegracht 12, Utrecht
interface Contact {
name: string;
email: string;
}
interface Address {
street: string;
city: string;
}
type Customer = Contact & Address;
const c: Customer = { name: "Joost Bakker", email: "joost@example.com", city: "Delft" };
example.ts(11,7): error TS2322: Type '{ name: string; email: string; city: string; }' is not assignable to type 'Customer'.
The naming can feel backward at first. An intersection has
more properties than either part, and a union lets you use
fewer. The set picture resolves it: a type is the set of
values that fit it. Values that fit both Contact and
Address are the overlap of the two sets, the
intersection, and those are exactly the objects that carry both sets of
properties.
Intersections are how a type alias extends another
type, the counterpart of interface … extends (see
Interface or type?). When both parts declare the same property with incompatible
types, there is no value that can satisfy both, and the property's
type becomes never, the empty type:
type A = { priority: number };
type B = { priority: string };
type C = A & B;
const x: C = { priority: 1 };
example.ts(4,16): error TS2322: Type 'number' is not assignable to type 'never'.
interface … extends reports such a conflict at the
declaration, which is one reason many teams prefer it for building
object types from other object types. Intersections shine for ad-hoc
combinations, such as "a ticket plus timestamps", and in
generic helper functions.
Choosing between them
| You want to say | Use | Example |
|---|---|---|
| The value is one of several types | Union | string | number |
| The value is one of a fixed set of values | Union of literal types | "open" | "in-progress" | "resolved" |
| The value may be missing |
Union with null or undefined
|
number | null |
| The value is one of several shapes | Discriminated union |
{ kind: "outage"; … } | { kind: "reading"; … }
|
| The value has all properties of several types | Intersection (or interface extends) |
Contact & Address |
Remember that these are all compile-time descriptions. The emitted
JavaScript contains no trace of Tariff or
Priority; a value that arrives from outside the program
is not checked against them unless you write the check yourself.