When building complex applications, we often encounter entities that come in different "flavors" - think products with different categories, users with various roles, or documents with distinct types. The challenge? How do we model these variants in TypeScript while maintaining type safety and developer experience?
The Common Anti-Pattern
Most developers start with what seems like the obvious solution - one interface with optional fields for everything:
interface IEntity {
id: string;
title: string;
type: 'product' | 'service' | 'subscription';
// Product-specific fields
weight?: number;
dimensions?: { width: number; height: number; depth: number };
// Service-specific fields
duration?: number;
location?: string;
// Subscription-specific fields
billingCycle?: 'monthly' | 'yearly';
features?: string[];
// ... and 20+ more optional fields
}
While this works functionally, it creates several problems:
Problems with the Optional Fields Approach
- No Type Safety: Nothing prevents you from setting weight on a service or duration on a product
- Poor Developer Experience: IntelliSense shows all fields for all types, making it confusing
- Runtime Errors: Easy to access undefined fields or set invalid combinations
- Maintenance Nightmare: Adding new types means touching one massive interface
- Unclear Requirements: Hard to tell which fields are actually required for each type
The Solution: Discriminated Unions
Discriminated unions (also called tagged unions) solve this elegantly by creating separate interfaces for each variant, then combining them with a union type.
Here's how to refactor the above example:
// Base interface with common properties
interface IBaseEntity {
id: string;
title: string;
description: string;
createdAt: Date;
updatedAt: Date;
}
// Product-specific interface
interface IProduct extends IBaseEntity {
type: 'product';
weight: number;
dimensions: {
width: number;
height: number;
depth: number;
};
sku: string;
inventory: number;
}
// Service-specific interface
interface IService extends IBaseEntity {
type: 'service';
duration: number;
location: string;
bookingRequired: boolean;
availability: string[];
}
// Subscription-specific interface
interface ISubscription extends IBaseEntity {
type: 'subscription';
billingCycle: 'monthly' | 'yearly';
features: string[];
trialPeriod: number;
maxUsers: number;
}
// Union type combining all variants
export type IEntity = IProduct | IService | ISubscription;
Type Guards for Runtime Safety
To work with these unions safely at runtime, create type guard functions:
export const isProduct = (entity: IEntity): entity is IProduct => {
return entity.type === 'product';
};
export const isService = (entity: IEntity): entity is IService => {
return entity.type === 'service';
};
export const isSubscription = (entity: IEntity): entity is ISubscription => {
return entity.type === 'subscription';
};
Usage in Practice
Now you can work with entities in a type-safe manner:
function processEntity(entity: IEntity) {
// Common properties are always available
console.log(entity.title);
// Type-specific logic with full type safety
if (isProduct(entity)) {
console.log(`Weight: ${entity.weight}kg`);
console.log(`SKU: ${entity.sku}`);
// entity.duration; // ❌ TypeScript error!
} else if (isService(entity)) {
console.log(`Duration: ${entity.duration} minutes`);
console.log(`Location: ${entity.location}`);
// entity.weight; // ❌ TypeScript error!
} else if (isSubscription(entity)) {
console.log(`Billing: ${entity.billingCycle}`);
console.log(`Features: ${entity.features.join(', ')}`);
}
}
Creating Clean Input Types
One challenge with extending Document (for Mongoose) or similar ORM interfaces is that they include methods you don't want in your input types. Here's how to create clean input types:
import { Document } from 'mongoose';
// Helper type that removes Document methods for clean inputs
export type CreateEntityInput<T extends IEntity['type']> =
T extends 'product' ? Omit<IProduct, keyof Document> :
T extends 'service' ? Omit<IService, keyof Document> :
T extends 'subscription' ? Omit<ISubscription, keyof Document> :
never;
// Usage in API controllers
export const createEntity = async (req: Request, res: Response) => {
const { type } = req.body;
// TypeScript knows exactly what fields are required based on type
const entityData: CreateEntityInput<typeof type> = req.body;
const entity = new Entity(entityData);
await entity.save();
};
File Organization
Keep your types organized for maintainability:
src/
├── types/
│ ├── index.ts # Export all types
│ ├── entity.types.ts # Entity-related types
│ ├── user.types.ts # User types
│ └── common.types.ts # Shared types
├── models/
│ └── Entity.ts
├── controllers/
└── services/
Advanced Pattern: Generic Functions
You can create generic functions that work with specific entity types:
function calculateShippingCost<T extends IEntity>(
entity: T
): T extends IProduct ? number : never {
if (isProduct(entity)) {
const volume = entity.dimensions.width * entity.dimensions.height * entity.dimensions.depth;
return (entity.weight * 0.1 + volume * 0.05) as any;
}
throw new Error('Shipping cost only applicable to products');
}
// Usage
const product: IProduct = { /* ... */ };
const cost = calculateShippingCost(product); // Returns number
const service: IService = { /* ... */ };
const cost2 = calculateShippingCost(service); // TypeScript error!
Benefits in Real-World Applications
1. Compile-Time Safety
// This will fail at compile time, not runtime
function processProduct(product: IProduct) {
console.log(product.duration); // ❌ TypeScript error
}
2. Better IntelliSense
When you type entity. in your IDE, you'll only see relevant fields based on the discriminated type.
3. Easier Refactoring
Need to add a new field to products? Just modify the IProduct interface. TypeScript will show you everywhere that needs updating.
4. Self-Documenting Code
The type system becomes documentation - it's immediately clear what fields are available for each entity type.
When to Use This Pattern
Discriminated unions are perfect for:
- Product catalogs with different product types
- User systems with different roles (admin, customer, vendor)
- Document management with different document types
- Payment methods (credit card, PayPal, bank transfer)
- Notification systems with different notification types
- Content management with different content types
Common Pitfalls to Avoid
- Don't make the discriminator optional: The type field must be required
- Use literal types: type: 'product' not type: string
- Don't mix concerns: Keep type-specific logic in the appropriate interfaces
- Consider maintenance: Don't create too many variants if they're very similar
Conclusion
Discriminated unions transform how you handle entity variants in TypeScript. They provide compile-time safety, better developer experience, and more maintainable code. While it requires a bit more upfront setup than the "optional fields everywhere" approach, the long-term benefits are substantial.
The next time you find yourself with an interface full of optional fields, consider whether discriminated unions might be a better fit. Your future self (and your teammates) will thank you for the improved type safety and clarity.
Have you used discriminated unions in your projects? What challenges did you face, and how did you solve them? Share your experience in the comments below!
Top comments (0)