Union Type in TypeScript
A value can belong to more than one type when it is a Union Type, which is indicated by the pipe symbol |
. A union type is conceptually the set-theoretic union (sum) of its constituent types; a value of A | B
indicates that it may come from either set (or both, if they overlap). The type set that results from using a union type is larger and includes all of the potential values from its members.
In JavaScript, union types are frequently used when a function parameter or property may accept various types of data. A command line utility may, for instance, accept the command as an array of strings or as a single string.
Basic Union Type and Usage
Allowing a variable to store either a string
or a number
is a simple example:
type StrOrNum = string | number; // Alias for string|number
// Usage
var sample: StrOrNum;
sample = 123; // OK
sample = '123'; // OK
// Safety check
sample = true; // Error!
Output/Type Safety: The last line produces a compile-time error: Error! Type 'true' is not assignable to type 'StrOrNum'
.
Handling Union Types via Narrowing
At first, TypeScript only permits property access and operations that are shared by all union members when interacting with a value typed as a union. You must use narrowing (or refining) techniques, usually with Type Guards, to access members that are exclusive to a certain type within the union.
The mechanism by which TypeScript determines that a value is of a more specific type than was originally specified from your code (for example, conditional logic) enables you to treat it as such within that conditional block. This is known as narrowing. For carrying out this analysis, TypeScript is aware of common JavaScript operators such as typeof
and instanceof
.
Take a look at a function that can handle an input that can be an array of strings or a single string:
function formatCommandline(command: string[] | string) {
var line = '';
// Type Guard: narrowing the type using typeof
if (typeof command === 'string') {
line = command.trim();
} else {
// Within this block, TypeScript knows 'command' must be string[]
line = command.join(' ').trim();
}
// Do stuff with line: string
console.log(line);
}
// Example Calls:
formatCommandline(" start ");
formatCommandline([" start ", " arg1 "]);
Output:
start
start arg1
Because there is no assurance that the command
is a string[]
in the wider scope, TypeScript would raise an error if you tried to call command.join()
outside of the else
block.
Discriminated Union Types for Complex Shapes
When modelling data shapes where different object types have some characteristics in common but differ greatly in other areas, union types are essential.
The use of a Discriminated Union significantly streamlines narrowing for complex object unions. This pattern necessitates:
- An object type union, such as
Shape = Square | Rectangle
. - A literal type (e.g.,
kind: "square"
) that is shared by all members (the discriminant).
TypeScript can restrict the type of the entire object and provide access to attributes that are specific to that member by verifying the value of the discriminant property (e.g., s.kind === "square"
).
Modelling geometric shapes, for example:
interface Square {
kind: "square"; // Literal type discriminant
size: number;
}
interface Rectangle {
kind: "rectangle"; // Literal type discriminant
width: number;
height: number;
}
type Shape = Square | Rectangle;
function area(s: Shape) {
if (s.kind === "square") {
// TypeScript knows 's' must be a Square
return s.size * s.size;
}
// TypeScript figures out it must be a Rectangle
return s.width * s.height;
}
// Example usage and implied narrowing:
const mySquare: Square = { kind: "square", size: 5 };
const myRectangle: Rectangle = { kind: "rectangle", width: 4, height: 6 };
console.log("Square area:", area(mySquare));
console.log("Rectangle area:", area(myRectangle));
Output:
Square area: 25
Rectangle area: 24
This illustrates how to safely traverse various object shapes inside a union by using the discriminant property (kind
) to initiate strong type narrowing.
Intersection Types
The attributes and members of two or more types are combined in an intersection type, which is indicated by the ampersand symbol &. A more limited set of possible values results from the resultant type having to meet the constraints of all constituent types. Similar to extending classes, intersection types are the same as combining properties.
Mirroring the popular JavaScript practice of combining properties from several objects to increase an object’s characteristics is a crucial use case.
Combining Object Features
You can safely define a new type that inherits all of the features from two or more existing object types by using the intersection operator.
To develop a SwissArmyKnife
type, think about integrating interfaces that define specialised tools:
interface Knife {
cut(): void;
}
interface BottleOpener{
openBottle(): void;
}
interface Screwdriver{
turnScrew(): void;
}
// SwissArmyKnife must possess all methods from Knife, BottleOpener, and Screwdriver
type SwissArmyKnife = Knife & BottleOpener & Screwdriver;
function use(tool: SwissArmyKnife){
console.log("I can do anything!");
tool.cut();
tool.openBottle();
tool.turnScrew();
}
// In a real implementation, you would need a value that matches this structure:
const multifunctionalTool: SwissArmyKnife = {
cut: () => console.log("Cutting..."),
openBottle: () => console.log("Opening bottle..."),
turnScrew: () => console.log("Turning screw...")
};
use(multifunctionalTool);
Output:
I can do anything!
Cutting...
Opening bottle...
Turning screw...
The SwissArmyKnife
intersection type’s combined requirements must be strictly followed by the tool
parameter.
Utility for Runtime Object Merging
Often called “extend” aids, intersection types offer type safety for runtime utility operations that combine objects. The following generic function takes two objects (T
and U
) and returns a new object type that is the intersection of both (T & U
):
// Generic extend function to safely merge properties of two objects
function extend<T, U>(first: T, second: U): T & U {
let result = <T & U> {};
// Logic to copy properties from first and second objects (omitted for brevity)
for (let id in first) {
(result as any)[id] = (first as any)[id];
}
for (let id in second) {
if (!result.hasOwnProperty(id)) {
(result as any)[id] = (second as any)[id];
}
}
return result;
}
var x = extend({ a: "hello" }, { b: 42 });
// The variable x is now inferred as { a: string } & { b: number }, or { a: string; b: number; }
var a = x.a; // Type: string
var b = x.b; // Type: number
Consequence of Type Inference: Because of the intersection return type T & U
, TypeScript properly deduces that x
has access to both the a
property (from the first argument) and the b
property (from the second argument).
Defining Base Structures for Complex Data
When designing types that have distinct extensions but a common fundamental shape, intersection types work very well. In order to minimise repetition, you can build derived types using intersections by establishing a base interface or type alias (DRY principle).
For example, integrating particular attributes for a conference
with shared properties defined for many event kinds (TechEventBase
):
// Define base properties shared by all events
type TechEventBase = {
title: string,
description: string,
date: Date,
capacity: number,
rsvp: number,
kind: string // A discriminant property might be refined here
}
// Create a specific event type by intersecting the base with unique properties
type Conference = TechEventBase & {
location: string,
price: number, // Unique property
talks: any[]
}
// If we hover over 'Conference', TypeScript sees the combination:
// { title: string, ..., kind: string, location: string, price: number, talks: any[] }
This design makes maintenance simpler by defining the distinctions between types and enabling the management of common attributes in a single location.
Summary of Roles
In TypeScript, union and intersection types are essential tools for modelling flexible data structures:
Union Types (|): One variable can securely represent several different types thanks to Union Types (|
), which expands the type set (e.g., a function argument accepting text or integer[]
). They allow robust type narrowing using type guards or discriminants when objects are involved.
- Intersection Types (&): The type set is constrained by types (
&
), which require a value to meet the specifications of all intersected types. They are essential for providing type advice for runtime object expansions, building complicated structures from reusable bases, and safely modelling composition.