Pattern matching in TypeScript has become an increasingly important feature for building robust and type-safe applications. While TypeScript doesn’t have built-in pattern matching like some functional programming languages, we can achieve similar functionality using various TypeScript features and techniques.
In this comprehensive guide, we’ll explore how to implement pattern matching patterns in TypeScript, providing you with practical examples and best practices along the way.
Table of Contents
- Understanding Pattern Matching
- Using Discriminated Unions
- Type Guards for Pattern Matching
- Pattern Matching with Object Literals
- Advanced Pattern Matching with Custom Types
- Real-World Example: State Management
- Best Practices
- Common Pitfalls and Solutions
- Conclusion
Understanding Pattern Matching
Pattern matching is a powerful programming concept that allows you to check a value against multiple patterns and execute different code based on which pattern matches. While TypeScript doesn’t have native pattern matching syntax like Rust or Haskell, we can achieve similar functionality using discriminated unions and type guards.
Using Discriminated Unions
Discriminated unions are one of the most powerful ways to implement pattern matching in TypeScript. They allow you to define a set of types that share a common discriminant property.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number }
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'rectangle':
return shape.width * shape.height
case 'triangle':
return (shape.base * shape.height) / 2
}
}
Code language: JavaScript (javascript)
Type Guards for Pattern Matching
Type guards provide another way to implement pattern matching by allowing you to narrow down types based on runtime checks.
type Result<T, E> = Success<T> | Error<E>
interface Success<T> {
type: 'success'
value: T
}
interface Error<E> {
type: 'error'
error: E
}
function isSuccess<T, E>(result: Result<T, E>): result is Success<T> {
return result.type === 'success'
}
function handleResult<T, E>(result: Result<T, E>): T | null {
if (isSuccess(result)) {
return result.value
} else {
console.error(result.error)
return null
}
}
Code language: PHP (php)
Pattern Matching with Object Literals
We can also use object literals to create pattern matching-like behavior:
type UserRole = 'admin' | 'user' | 'guest'
const rolePermissions = {
admin: () => ['read', 'write', 'delete'],
user: () => ['read', 'write'],
guest: () => ['read']
}
function getPermissions(role: UserRole): string[] {
return rolePermissions[role]()
}
Advanced Pattern Matching with Custom Types
For more complex scenarios, we can create custom types that enable sophisticated pattern matching:
type Maybe<T> =
| { type: 'just'; value: T }
| { type: 'nothing' }
function match<T, R>(
maybe: Maybe<T>,
patterns: {
just: (value: T) => R
nothing: () => R
}
): R {
switch (maybe.type) {
case 'just':
return patterns.just(maybe.value)
case 'nothing':
return patterns.nothing()
}
}
// Usage example
const result: Maybe<number> = { type: 'just', value: 42 }
const message = match(result, {
just: (value) => `Got value: ${value}`,
nothing: () => 'No value present'
})
Code language: JavaScript (javascript)
Real-World Example: State Management
Here’s a practical example of using pattern matching for handling application state:
type AppState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: any }
| { status: 'error'; error: string }
function renderUI(state: AppState): string {
switch (state.status) {
case 'idle':
return 'Application is idle'
case 'loading':
return 'Loading...'
case 'success':
return `Data loaded: ${JSON.stringify(state.data)}`
case 'error':
return `Error occurred: ${state.error}`
}
}
Code language: JavaScript (javascript)
Best Practices
- Always use exhaustive checking in switch statements to catch potential missed cases:
function assertNever(x: never): never {
throw new Error('Unexpected object: ' + x)
}
function handleShape(shape: Shape) {
switch (shape.kind) {
case 'circle':
// handle circle
break
case 'rectangle':
// handle rectangle
break
case 'triangle':
// handle triangle
break
default:
assertNever(shape) // Will error if a case is missing
}
}
Code language: PHP (php)
- Use type predicates for complex type narrowing:
function isArrayOfNumbers(value: unknown): value is number[] {
return Array.isArray(value) && value.every(item => typeof item === 'number')
}
Common Pitfalls and Solutions
Avoiding Type Widening
When working with pattern matching, be careful of type widening:
// Bad
const shape = { kind: 'circle' as const, radius: 5 }
// Good
const shape = { kind: 'circle', radius: 5 } as const
Code language: JavaScript (javascript)
Handling Optional Properties
When dealing with optional properties, make sure to handle all cases:
type User = {
name: string
age?: number
}
function describeUser(user: User): string {
return match(user, {
withAge: (age) => `${user.name} is ${age} years old`,
withoutAge: () => `${user.name}'s age is unknown`
})
}
function match<T extends User, R>(
user: T,
patterns: {
withAge: (age: number) => R
withoutAge: () => R
}
): R {
return user.age !== undefined
? patterns.withAge(user.age)
: patterns.withoutAge()
}
Code language: JavaScript (javascript)
Conclusion
While TypeScript doesn’t have native pattern matching syntax, we can achieve powerful pattern matching functionality using discriminated unions, type guards, and careful type design. By following these patterns and best practices, you can write more type-safe and maintainable code.
For more advanced TypeScript topics, check out our guide on TypeScript Type Guards: A Complete Guide to Runtime Type Checking which dives deeper into type safety mechanisms.