TypeScript Union Types
TypeScript union types let you define variables, parameters, or return types that accept multiple types. Using the pipe symbol (|), you can create a type that represents "this OR that" - giving you flexibility without sacrificing type safety. Union types help you write functions that handle different input formats, create flexible data structures, and simplify conditional logic.
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 Literal Types in Unions
One of the most powerful features of union types is combining them with literal types. Instead of accepting any string or number, you can specify exactly which values are valid:
// Only these specific strings are allowed
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
function makeRequest(url: string, method: HttpMethod) {
// TypeScript provides autocomplete for method parameter
// and prevents typos at compile time
}
makeRequest('/api/users', 'GET'); // ✓ Valid
makeRequest('/api/users', 'PATCH'); // ✗ Error: not in union
// Numeric literals work too
type HttpStatus = 200 | 201 | 400 | 401 | 403 | 404 | 500;
function handleResponse(status: HttpStatus) {
// Your editor will autocomplete valid status codes
}
This pattern is incredibly useful for configuration options, status codes, user roles, or any scenario where you want to restrict values to a specific set. You get autocomplete in your editor and catch typos before runtime.
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. The typeof operator helps 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.toUpperCase());
} else {
// TypeScript knows value is a number here
console.log('Number value:', value.toFixed(2));
}
}
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.
When Union Types Break: Debugging Common Errors
The most common error you'll encounter with union types is "Property does not exist on type." This happens when you try to access a property that doesn't exist on all types in the union:
interface ApiSuccess {
data: any;
timestamp: string;
}
interface ApiError {
error: string;
code: number;
}
type ApiResponse = ApiSuccess | ApiError;
function handleResponse(response: ApiResponse) {
console.log(response.data);
// ✗ Error: Property 'data' does not exist on type 'ApiResponse'
// Property 'data' does not exist on type 'ApiError'
}
Why does this fail? TypeScript only lets you access properties that exist on every type in the union. Since ApiError doesn't have a data property, you can't access it without narrowing first.
Here's how to fix it:
function handleResponse(response: ApiResponse) {
// Use the 'in' operator to check if property exists
if ('data' in response) {
// TypeScript now knows this is ApiSuccess
console.log('Success:', response.data);
console.log('Timestamp:', response.timestamp);
} else {
// TypeScript knows this is ApiError
console.log('Error:', response.error);
console.log('Code:', response.code);
}
}
You can also use type guards with typeof for primitive unions:
function formatId(id: string | number) {
// Can't call .toUpperCase() without checking first
if (typeof id === 'string') {
return id.toUpperCase(); // Safe now
}
return `ID-${id}`; // TypeScript knows id is number here
}
The key rule: always narrow your union types before accessing type-specific properties or methods.
Union Types vs Intersection Types
Understanding when to use union types (|) versus intersection types (&) is crucial. They solve different problems:
| Feature | Union Types (A | B) | Intersection Types (A & B) |
|---|---|---|
| Meaning | Value can be type A or type B | Value must be type A and type B |
| Properties | Only properties common to both types are accessible without narrowing | All properties from both types are required |
| Use Case | Multiple valid options (different shapes) | Combining multiple type requirements |
| Frequency | Very common (~50x more than intersections) | Less common, specific scenarios |
Here's a practical example:
// Union: User is EITHER a guest OR authenticated
type Guest = { sessionId: string };
type Authenticated = { userId: string; email: string };
type User = Guest | Authenticated;
// You must narrow to access specific properties
function greetUser(user: User) {
if ('userId' in user) {
console.log(`Welcome back, ${user.email}!`);
} else {
console.log(`Guest session: ${user.sessionId}`);
}
}
// Intersection: Config must have BOTH features
type Timestamped = { createdAt: Date; updatedAt: Date };
type Identifiable = { id: string };
type Entity = Timestamped & Identifiable;
// Entity requires ALL properties from both types
const article: Entity = {
id: 'abc123',
createdAt: new Date(),
updatedAt: new Date(),
// Missing any of these would be an error
};
Use unions when you have different valid shapes (like different message types, API responses, or user states). Use intersections when you need to combine multiple requirements into one type.
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.email);
} else if ('role' in data) {
console.log('Admin data:', data.role);
}
}
This pattern works, but there's a better approach called a discriminated union. Discriminated unions use a common property 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 API Responses with Union Types
Union types provide a powerful way to handle variations in API responses without resorting to optional properties. This is particularly useful for representing mutually exclusive response states:
// Success and error responses are mutually exclusive
type ApiSuccess = {
status: 'success';
data: any;
timestamp: string;
};
type ApiError = {
status: 'error';
message: string;
code: number;
};
type ApiResponse = ApiSuccess | ApiError;
function processApiResponse(response: ApiResponse): void {
if (response.status === 'success') {
// TypeScript knows data and timestamp exist
console.log(`Data received at ${response.timestamp}:`, response.data);
} else {
// TypeScript knows message and code exist
console.log(`Error ${response.code}: ${response.message}`);
}
}
This approach enforces that you always have the correct set of properties for each response type. You can't accidentally have a response with both data and error, unlike with 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 {
// Both types have 'id', but different types
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 Discriminated Unions
Discriminated unions (also called tagged unions) are one of the most powerful patterns in TypeScript. They use a common literal property to distinguish between different shapes, making type narrowing automatic and exhaustive:
// 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}`;
}
}
Exhaustiveness Checking with Discriminated Unions
One of the biggest advantages of discriminated unions is exhaustiveness checking. TypeScript can verify you've handled all possible cases:
function renderMessage(message: Message): string {
switch (message.kind) {
case 'text':
return `${message.sender}: ${message.text}`;
case 'image':
return `${message.sender} shared an image: ${message.caption || '[No caption]'}`;
// What if we forgot 'system'?
}
// Error: Function lacks ending return statement
}
You can make this even more explicit using the never type:
function renderMessage(message: Message): string {
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}`;
default:
// If we add a new message type and forget to handle it,
// TypeScript will catch it here
const exhaustiveCheck: never = message;
throw new Error(`Unhandled message type: ${exhaustiveCheck}`);
}
}
This type narrowing technique is cleaner than using if/else chains with property checks, and TypeScript ensures that all possible cases are handled. If you add a new message type later and forget to handle it in your switch statement, TypeScript will show a compile-time error.
Key Takeaways for Working with Union Types
Union types are a fundamental 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 managing complex state, union types offer elegant solutions to common challenges.
Here's what to remember:
- Use literal types in unions to get autocomplete and prevent typos
- Always narrow union types with type guards before accessing type-specific properties
- Choose unions for "OR" scenarios (different shapes) and intersections for "AND" scenarios (combining requirements)
- Prefer discriminated unions with a
kindortypeproperty for complex objects - Use exhaustiveness checking with the
nevertype to catch missing cases at compile time - The "property does not exist" error means you need to narrow your type first
Apply these patterns in your projects to make your code more robust and expressive while maintaining the type safety that makes TypeScript valuable.