DEV Community

Aarav Pradhan
Aarav Pradhan

Posted on

Building Type-Safe Entity Management with TypeScript Discriminated Unions

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
}

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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';
};

Enter fullscreen mode Exit fullscreen mode

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(', ')}`);
    }
}

Enter fullscreen mode Exit fullscreen mode

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();
};

Enter fullscreen mode Exit fullscreen mode

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/

Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Don't make the discriminator optional: The type field must be required
  2. Use literal types: type: 'product' not type: string
  3. Don't mix concerns: Keep type-specific logic in the appropriate interfaces
  4. 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)