TypeScript Union Types
TypeScript union types allow you to define variables, parameters, or return types that can accept multiple types. Using the pipe symbol (|
), you can create a type that represents "this OR that" - a powerful feature that enables more flexible code while maintaining type safety. Union types help you write functions that handle different input formats, create flexible data structures, and simplify conditional logic.
With union types, you might define an ID that can be either a string or a number, a function that accepts different parameter types, or a composite type representing various states of an application. When used correctly, union types make your code more adaptable while still providing strong type checking.
Combining Different Types with Union Types
Union types are handy when dealing with different data types in a single variable or function parameter. For example, you can define a variable id
that can store either a string
or a number
:
// ID can be either a string or a number
let id: string | number = '123';
id = 123; // Also valid
// Function that accepts multiple input types
function displayId(id: string | number) {
console.log(`The ID is: ${id}`);
}
This flexibility is particularly valuable when dealing with external data sources like user input, API responses, or database queries where the exact type might vary. In Convex, validators can use union types to accept different but valid input formats, making your backend functions more robust.
Using Union Types for Better Type Safety
To ensure type safety with union types, use type guards to narrow down the type within a union. Type guards help TypeScript understand which type is being used at a specific point in your code:
function parseValue(value: string | number): void {
if (typeof value === 'string') {
// TypeScript knows value is a string here
console.log('String value:', value);
} else if (typeof value === 'number') {
// TypeScript knows value is a number here
console.log('Number value:', value);
}
}
Type guards like typeof
, instanceof
, and property checks (in
operator) allow you to safely access type-specific properties and methods. This technique, known as type narrowing, prevents runtime errors and provides better editor support.
Handling Multiple Data Formats with Union Types
Union types can manage multiple data formats, like JSON data with different structures. For example:
interface User {
name: string;
email: string;
}
interface Admin {
name: string;
role: string;
}
type UserData = User | Admin;
function processUserData(data: UserData): void {
if ('email' in data) {
console.log('User data:', data);
} else if ('role' in data) {
console.log('Admin data:', data);
}
}
This pattern, called a discriminated union, uses a common property (here, type
) to distinguish between different object structures. It's particularly useful when working with Convex where the VUnion validator can handle different data formats while maintaining type safety.
Defining Union Types for Flexible Function Parameters
Union types allow for flexible function parameters that can accept different data types. For example:
function formatName(name: string | { firstName: string; lastName: string }): string {
if (typeof name === 'string') {
return name;
} else {
return `${name.firstName} ${name.lastName}`;
}
}
// Both calls are valid
formatName("John Doe");
formatName({ firstName: "John", lastName: "Doe" });
This pattern is useful in libraries where you want to provide a simple API while supporting advanced options. The Convex server module uses this approach for function parameters, accepting either simple values or complex objects with additional configuration.
Managing Optional Properties with Union Types
Union types provide a powerful way to handle variations in object structures without resorting to optional properties. This is particularly useful for representing mutually exclusive configurations:
type Address = {
street: string;
city: string;
state?: string;
zip?: string;
} | {
street: string;
city: string;
country: string;
};
function printAddress(address: Address): void {
if ('state' in address) {
console.log(`Address: ${address.street}, ${address.city}, ${address.state} ${address.zip}`);
} else if ('country' in address) {
console.log(`Address: ${address.street}, ${address.city}, ${address.country}`);
}
}
This approach enforces that you always have the correct set of properties for each address type, unlike optional properties which might allow invalid combinations. In Convex, this pattern helps define schema validators that enforce data consistency while accommodating different document structures.
Resolving Union Type Conflicts
Union type conflicts can occur when working with third-party libraries or APIs expecting specific type structures. Use type guards and narrowing to resolve these conflicts. For example:
interface ThirdPartyUser {
id: number;
name: string;
}
interface LocalUser {
id: string;
name: string;
}
type User = ThirdPartyUser | LocalUser;
function processUser(user: User): void {
if ('id' in user) {
if (typeof user.id === 'number') {
console.log('Third-party user:', user);
} else {
console.log('Local user:', user);
}
}
}
This pattern is essential when integrating with systems like Convex where you might need to handle both client-provided data and server-generated documents that share some properties but differ in others.
Simplifying Complex Logic with Union Types
Union types can simplify complex conditional logic using type guards and narrowing. For example:
// Different message types in a chat application
type TextMessage = {
kind: 'text';
text: string;
sender: string;
};
type ImageMessage = {
kind: 'image';
imageUrl: string;
caption?: string;
sender: string;
};
type SystemMessage = {
kind: 'system';
text: string;
};
type Message = TextMessage | ImageMessage | SystemMessage;
function renderMessage(message: Message): string {
// The discriminant property 'kind' helps TypeScript narrow the type
switch (message.kind) {
case 'text':
return `${message.sender}: ${message.text}`;
case 'image':
return `${message.sender} shared an image: ${message.caption || '[No caption]'}`;
case 'system':
return `SYSTEM: ${message.text}`;
}
}
This type narrowing technique is cleaner than using if/else chains with property checks, and TypeScript ensures that all possible cases are handled. The pattern shown above is a discriminated union (also called tagged unions), which is one of the most powerful ways to leverage union types in TypeScript.
Final Thoughts on TypeScript Union Types
Union types are an essential part of TypeScript's type system, allowing you to build flexible yet type-safe code. Whether you're handling varied data formats, creating adaptable APIs, or simplifying complex logic, union types offer elegant solutions to common programming challenges. Apply these patterns in your projects to make your code more robust and expressive.