Union Types and Type Narrowing
In the TypeScript type system, Union Types and Type Narrowing function as two sides of the same coin. While Union Types allow a value to represent one of several possible types, Type Narrowing provides the mechanism to refine those possibilities into a specific, concrete type within a particular block of code. Mastering this interplay is essential for writing robust, type-safe applications that leverage the full power of the compiler.
A Union Type allows a variable to hold more than one type of value. By using the pipe operator (|), you can declare that a value can be "this OR that."
let id: string | number;
id = "A-1024"; // Valid
id = 1024; // Valid
id = true; // Error: Type 'boolean' is not assignable to type 'string | number'
Union types are ubiquitous in modern TypeScript development. You will frequently encounter them in:
- Function Parameters: When a function needs to handle different input formats (e.g., a single ID or an array of IDs).
- API Responses: When a return value might be a successful data object or an error object.
- State Management: When a status can only be one of a few specific string literals, such as
"idle" | "loading" | "success" | "error".
The Access Limitation
The primary challenge with Union Types is that the TypeScript compiler is inherently cautious. When you are working with a union, you can only safely access properties or methods that are common to all members of that union.
function format(value: string | number) {
// Error: Property 'toUpperCase' does not exist on type 'number'.
// The compiler cannot guarantee that 'value' is a string at runtime.
console.log(value.toUpperCase());
}
Because the compiler cannot predict which type will be present at runtime, it prevents you from accessing type-specific members. To bridge this gap, you must use Type Narrowing.
Essential Type Narrowing Techniques
Type narrowing is the process of using logic to prove to the compiler that a value belongs to a specific subtype.
1. Using typeof for Primitives
For primitive types like string, number, and boolean, the typeof operator is the most straightforward approach.
function formatValue(value: string | number): string {
if (typeof value === "string") {
// Within this block, 'value' is narrowed to 'string'
return value.toUpperCase();
}
// Outside the block, 'value' is narrowed to 'number'
return value.toFixed(2);
}
Note: Be mindful that typeof null returns "object", which can lead to unexpected behavior if you aren't explicitly checking for nullability.
2. Using instanceof for Objects and Classes
When dealing with class instances or built-in objects like Date, the instanceof operator is the preferred tool.
function logDateOrString(value: Date | string) {
if (value instanceof Date) {
// 'value' is narrowed to 'Date'
console.log(value.toISOString());
} else {
// 'value' is narrowed to 'string'
console.log(value.trim());
}
}
3. The in Operator for Property Checking
If you are working with object types, you can check for the existence of a specific property using the in operator. This is particularly useful for distinguishing between different interfaces.
interface Bird { fly(): void; }
interface Fish { swim(): void; }
function move(animal: Bird | Fish) {
if ("swim" in animal) {
// 'animal' is narrowed to 'Fish'
animal.swim();
} else {
// 'animal' is narrowed to 'Bird'
animal.fly();
}
}
4. Literal Equality
For unions composed of string or number literals, simple equality checks (===) are sufficient to narrow the type.
type Status = "idle" | "loading" | "success" | "error";
function renderStatus(status: Status) {
if (status === "loading") {
// 'status' is narrowed to the literal type "loading"
console.log("Loading...");
}
}
Advanced Pattern: Discriminated Unions
In complex application logic, the most powerful pattern is the Discriminated Union (also known as Tagged Unions). By adding a common literal property (a "tag" or "kind") to every member of the union, you can use a switch statement to create highly predictable and readable narrowing logic.
interface Circle { kind: "circle"; radius: number; }
interface Rect { kind: "rect"; width: number; height: number; }
type Shape = Circle | Rect;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
// shape is narrowed to Circle
return Math.PI * shape.radius ** 2;
case "rect":
// shape is narrowed to Rect
return shape.width * shape.height;
}
}
Exhaustiveness Checking with never
To make Discriminated Unions even safer, you can implement exhaustiveness checking. By using the never type in a default case, you ensure that the compiler will throw an error if you ever add a new member to the union without updating your logic.
function assertNever(x: never): never {
throw new Error("Unexpected object: " + JSON.stringify(x));
}
function getAreaSafe(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "rect": return shape.width * shape.height;
default:
// If a new shape (e.g., Triangle) is added to the Shape union,
// TypeScript will flag an error here because 'shape' won't be 'never'.
return assertNever(shape);
}
}
Custom Type Guards
Sometimes, built-in operators are insufficient for complex validation logic. In these cases, you can define a User-Defined Type Guard using the parameter is Type syntax.
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === "string");
}
const input: unknown = ["apple", "banana"];
if (isStringArray(input)) {
// 'input' is now safely treated as string[]
input.map(s => s.toUpperCase());
}
Handling Null and Undefined
In production-grade TypeScript, especially when strictNullChecks is enabled, handling null and undefined is a critical part of narrowing. Beyond explicit equality checks, truthiness is a common way to narrow out nullish values.
function greet(name: string | null) {
if (name) {
// 'name' is narrowed to 'string' because null is falsy
console.log(`Hello, ${name.toUpperCase()}!`);
} else {
console.log("Hello, stranger!");
}
}
Summary
- Union Types define a value that can be one of several types, but restrict access to only the shared members.
- Type Narrowing uses control flow analysis to refine a union into a specific type.
- Common tools include
typeof,instanceof, theinoperator, and literal comparisons. - Discriminated Unions combined with the
nevertype provide the most robust way to model complex data structures and ensure exhaustive handling. - Custom Type Guards allow you to encapsulate complex validation logic into reusable, type-safe functions.
By mastering these patterns, you move beyond simply "writing types" to truly leveraging TypeScript to build resilient, self-documenting, and error-resistant codebases.