Why Rethink Switch Statements
TypeScript's switch statement has been a staple of JavaScript development for decades, but as our applications grow more complex, we're discovering that switch doesn't always give us the type safety and maintainability we need. This guide explores modern alternatives that can make your code cleaner, safer, and more expressive.
The Evolution of TypeScript
The traditional switch statement was designed for a simpler era of JavaScript programming, before TypeScript's sophisticated type system existed. While it served its purpose well, modern TypeScript offers powerful alternatives that leverage the type system for better compile-time safety.
When Switch Falls Short
Traditional switch statements come with significant drawbacks in modern TypeScript development:
- Fall-through behavior leading to unexpected bugs
- No built-in type narrowing within case blocks
- Difficult to extend with new cases
- Verbose syntax for simple mappings
- Limited support for complex pattern matching
- No exhaustiveness checking for union types
Scenarios Where Alternatives Excel
- Complex type guards - When you need to narrow types based on multiple conditions
- State machines - Modeling states with different associated data
- API response handling - Dealing with multiple response shapes
- Extensible systems - When new cases need to be added dynamically
For web development projects that require maintainable, type-safe codebases, choosing the right branching pattern becomes critical as applications scale.
Object Literal Pattern
The object literal pattern replaces switch statements with simple object lookups. This approach works beautifully for mapping values to results.
Simple Value Mapping
// Traditional switch
function getPriority(level: string): number {
switch (level) {
case 'low': return 1;
case 'medium': return 2;
case 'high': return 3;
case 'critical': return 4;
default: return 0;
}
}
// Object literal pattern
const priorityMap: Record<string, number> = {
low: 1,
medium: 2,
high: 3,
critical: 4,
};
function getPriority(level: string): number {
return priorityMap[level] ?? 0;
}
Benefits
- Concise and readable
- Easy to maintain and extend
- Constant-time lookup performance
- Can be defined outside functions for reuse
This pattern is particularly effective for frontend development tasks where configuration-driven behavior reduces boilerplate code.
Object Pattern with Function Values
For cases where each case needs to execute logic, you can map to function references. This pattern extends the object literal approach to handle complex operations while maintaining clean, declarative syntax.
const handlers: Record<string, (input: number) => string> = {
positive: (n) => n > 0 ? 'positive' : 'not positive',
negative: (n) => n < 0 ? 'negative' : 'not negative',
zero: (n) => n === 0 ? 'zero' : 'not zero',
};
function describeNumber(n: number): string {
return handlers.zero(n);
}
Practical Use Cases
Form Validation - Map validation rules to handler functions based on input type, enabling clean separation of validation logic:
type Validator = (value: string) => { valid: boolean; message: string };
const validators: Record<string, Validator> = {
email: (v) => ({ valid: v.includes('@'), message: 'Invalid email' }),
phone: (v) => ({ valid: /^\d{10}$/.test(v), message: 'Invalid phone' }),
url: (v) => ({ valid: v.startsWith('http'), message: 'Invalid URL' }),
};
function validateField(value: string, type: string) {
const validator = validators[type];
return validator ? validator(value) : { valid: true, message: '' };
}
API Response Processing - Handle different response types with dedicated processing functions:
type ResponseHandler = (response: unknown) => string;
const responseHandlers: Record<number, ResponseHandler> = {
200: (r) => `Success: ${JSON.stringify(r)}`,
404: () => 'Resource not found',
500: () => 'Server error occurred',
};
function handleResponse(status: number, response: unknown): string {
const handler = responseHandlers[status] || (() => 'Unknown status');
return handler(response);
}
Dynamic Command Processing - Build extensible CLI or bot frameworks where new commands register themselves:
type CommandHandler = (args: string[]) => Promise<string>;
const commands: Record<string, CommandHandler> = {
help: async () => 'Available commands: help, status, exit',
status: async () => 'System operational',
exit: async () => 'Goodbye!',
};
async function executeCommand(cmd: string, args: string[]): Promise<string> {
const handler = commands[cmd.toLowerCase()];
return handler ? handler(args) : `Unknown command: ${cmd}`;
}
Discriminated Unions and Type Narrowing
Discriminated unions combined with TypeScript's type narrowing capabilities provide a type-safe alternative to switch statements that scales beautifully.
What Are Discriminated Unions?
A discriminated union is a type that can be one of several variants, where each variant has a common "discriminant" property that TypeScript can use to narrow the type.
State Machine Example
type LoadingState = { status: 'loading' };
type SuccessState = { status: 'success'; data: string };
type ErrorState = { status: 'error'; error: string };
type FetchState = LoadingState | SuccessState | ErrorState;
function processState(state: FetchState): string {
switch (state.status) {
case 'loading':
return 'Loading data...';
case 'success':
return `Data: ${state.data.toUpperCase()}`;
case 'error':
return `Error: ${state.error.toLowerCase()}`;
}
}
TypeScript automatically narrows the type within each case block, giving you compile-time confidence that the properties you access exist.
This approach is fundamental to building robust web applications with predictable state management.
Exhaustiveness Checking
One of TypeScript's most powerful features is its ability to catch missing cases through exhaustiveness checking:
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
function processState(state: FetchState): string {
switch (state.status) {
case 'loading':
return 'Loading...';
case 'success':
return `Success: ${state.data}`;
case 'error':
return `Error: ${state.error}`;
default:
assertNever(state);
}
}
If you add a new variant to FetchState but forget to handle it, TypeScript will warn you at compile time. This catch-at-compile-time approach prevents entire categories of bugs from reaching production.
For custom web applications handling complex business logic, exhaustiveness checking ensures your type definitions stay in sync with your implementation.
Switch-True Narrowing (TypeScript 5.3+)
TypeScript 5.3 introduced switch-true narrowing, a technique that lets you write conditional logic that feels like pattern matching without leaving the switch syntax.
The Concept
Instead of switching on a variable's value, you switch on true and use each case as a boolean condition. This approach lets you express complex conditional logic more declaratively.
// Before: Traditional if/else chains
function calculateScore(result?: number | null | undefined): string {
if (result !== null && result !== undefined && result <= -100) {
return 'OVERSOLD';
} else if (result !== null && result !== undefined && result >= 100) {
return 'OVERBOUGHT';
} else {
return 'UNKNOWN';
}
}
// After: Switch-true narrowing
function calculateScore(result?: number | null | undefined): string {
const hasResult = result !== null && result !== undefined;
const isOversold = hasResult && result <= -100;
const isOverbought = hasResult && result >= 100;
switch (true) {
case isOversold:
return 'OVERSOLD';
case isOverbought:
return 'OVERBOUGHT';
default:
return 'UNKNOWN';
}
}
As covered by TypeScript TV's guide to switch-true narrowing, this pattern provides a middle ground between traditional switch and full pattern matching libraries.
How Switch-True Narrowing Works
The pattern relies on a simple runtime behavior: switch (true) evaluates each case expression as a boolean, executing the first case that evaluates to true.
switch (true) {
case 5 > 3:
console.log('Math works');
break;
case 10 < 2:
console.log('Never runs');
break;
}
By extracting conditions into named boolean variables, you make the intent explicit while enabling TypeScript's type narrowing within each branch. This approach combines the readability of switch with the type safety of modern TypeScript features.
Real-World Application
Consider a form validation system that needs to evaluate multiple conditions in priority order:
type ValidationResult = 'valid' | 'too-short' | 'too-long' | 'invalid-chars';
function validateUsername(input: string): ValidationResult {
const hasContent = input.length > 0;
const isTooShort = hasContent && input.length < 3;
const isTooLong = input.length > 20;
const hasInvalidChars = hasContent && !/^[a-zA-Z0-9_]+$/.test(input);
switch (true) {
case isTooShort:
return 'too-short';
case isTooLong:
return 'too-long';
case hasInvalidChars:
return 'invalid-chars';
case !hasContent:
return 'valid';
default:
return 'valid';
}
}
This pattern excels in enterprise web applications where complex business rules require clear, maintainable conditional logic.
The ts-pattern Library
For projects that need true pattern matching with exhaustiveness checking and expressive syntax, the ts-pattern library provides a comprehensive solution.
What ts-pattern Offers
- Pattern matching with exhaustiveness checking
- Deep object and array pattern matching
- Conditional guards within patterns
- Type-safe composition
import { match } from 'ts-pattern';
type State =
| { type: 'loading' }
| { type: 'success'; data: string }
| { type: 'error'; message: string };
function processState(state: State): string {
return match(state)
.with({ type: 'loading' }, () => 'Loading...')
.with({ type: 'success' }, (s) => `Success: ${s.data}`)
.with({ type: 'error' }, (e) => `Error: ${e.message}`)
.exhaustive();
}
As documented in the ts-pattern repository, this library brings functional pattern matching to TypeScript with full type inference.
When to Use ts-pattern
Use ts-pattern when:
- You need deep pattern matching on object structures
- Exhaustiveness checking is critical
- You prefer declarative syntax
- You're building complex state machines
- Your team is comfortable with functional programming concepts
Consider simpler alternatives when:
- Your logic is straightforward
- You want to minimize dependencies
- Your team prefers imperative code
- Performance is critical (function calls have overhead)
Deep Pattern Matching Example
import { match, when } from 'ts-pattern';
type User =
| { role: 'admin'; permissions: string[] }
| { role: 'user'; email: string }
| { role: 'guest' };
function getAccessLevel(user: User): string {
return match(user)
.with({ role: 'admin' }, (u) => `Full access: ${u.permissions.join(', ')}`)
.with({ role: 'user', email: when((e) => e.endsWith('@company.com')) }, () => 'Corporate user access')
.with({ role: 'user' }, () => 'Standard user access')
.with({ role: 'guest' }, () => 'Limited access')
.exhaustive();
}
For scalable web applications with complex domain models, ts-pattern provides the expressive power needed to handle intricate conditional logic elegantly.
Performance Considerations
Understanding performance helps choose the right pattern for your use case.
| Approach | Time Complexity | Memory | Best For |
|---|---|---|---|
| Object Literal | O(1) | Single object | Frequent calls with same mapping |
| Traditional Switch | O(n) worst | Per-call | Few cases, fall-through needed |
| Discriminated Unions | Varies | Compile-time | Complex state management |
| Switch-True | O(n) worst | Per-call | Complex conditional logic |
| ts-pattern | Varies | Function overhead | Type safety over micro-optimization |
Key Takeaways
- Object literals offer the best raw performance for simple mappings
- Discriminated unions have no runtime overhead but catch bugs at compile time
- ts-pattern adds function call overhead but provides the most expressive syntax
- For most applications, the performance difference between patterns is negligible
As noted in DEV Community's TypeScript patterns guide, prioritizing type safety often provides more value than micro-optimizations in modern applications.
For high-performance web applications, profile your specific hot paths before choosing based on theoretical performance alone.
| Scenario | Recommended Approach |
|---|---|
| Simple value mapping | Object literal |
| Type-safe state handling | Discriminated unions |
| Complex boolean conditions | Switch-true narrowing |
| Deep pattern matching | ts-pattern |
| Interchangeable algorithms | Strategy pattern |
| Maximizing performance | Object literal or switch |
Best Practices
-
Start simple - Use object literals for straightforward mappings before reaching for complex patterns.
-
Leverage TypeScript's type system - Discriminated unions and exhaustiveness checking prevent bugs at compile time.
-
Consider maintenance - Patterns that make adding new cases easy will save time as your codebase evolves.
-
Be consistent - Mixing patterns in the same codebase creates cognitive load. Establish team conventions.
-
Profile when it matters - Most applications won't notice performance differences between patterns. Focus on readability first.
-
Document your choices - Explain why you chose a pattern so future maintainers understand the reasoning behind architectural decisions.
Questions to Consider
- How many cases will this pattern need to handle?
- Does this code path require type safety guarantees?
- Will new cases need to be added dynamically?
- What's the team's familiarity with functional patterns?
- Is this a hot path that requires micro-optimization?
By thoughtfully selecting the right pattern for each scenario, your web development projects will benefit from code that's both maintainable and robust.
Conclusion
TypeScript offers multiple alternatives to traditional switch statements, each with distinct advantages. The right choice depends on your specific requirements:
- Object literals for simple, fast value mappings
- Discriminated unions for type-safe state handling
- Switch-true narrowing for complex conditional logic
- ts-pattern for full-featured pattern matching
- Functional patterns for composable, testable code
By understanding these alternatives, you can write TypeScript code that's not just functional, but maintainable, type-safe, and expressive. The modern TypeScript ecosystem provides tools that leverage the type system to catch bugs at compile time rather than runtime.
Whether you're building custom web applications, enterprise software, or open-source libraries, choosing the right branching pattern improves code quality and reduces maintenance burden over time.
Frequently Asked Questions
When should I stop using switch statements?
Consider alternatives when you need type narrowing, exhaustiveness checking, or when your switch statements become difficult to maintain. Simple switches with few cases don't always need refactoring. The key is to reach for alternatives when complexity grows beyond what switch handles elegantly.
Is switch-true narrowing available in all TypeScript versions?
Switch-true narrowing is available in TypeScript 5.3 and later. For older projects, you'll need to use one of the other alternatives like object literals or discriminated unions. Check your TypeScript version with `tsc --version` before using this feature.
Does ts-pattern add significant bundle size?
ts-pattern is relatively lightweight, but it does add some overhead. For applications where bundle size is critical, consider using native TypeScript patterns instead. The library uses tree-shaking well, but the runtime cost is worth evaluating for your specific use case.
Which pattern is fastest for performance?
Object literal lookups offer O(1) constant-time performance with minimal overhead. For most applications, the difference between patterns is negligible. Profile your specific use case if performance is critical, but prioritize readability and type safety first in most scenarios.
Sources
-
TypeScript TV - Switch-True Narrowing in TypeScript - Comprehensive guide to switch-true narrowing implementation and comparison to traditional patterns.
-
DEV Community - TypeScript Advanced Patterns - Overview of advanced TypeScript patterns including discriminated unions and type-safe coding practices.
-
ts-pattern GitHub Repository - Official documentation for the TypeScript pattern matching library with exhaustiveness checking.