← all writings

Wrong Abstractions: The Type Safety Trap

Why most TypeScript interfaces in modern web apps aren't real abstractions—and how overusing them for 'type safety' leads to code coupling and anti-patterns. Learn better approaches, with practical NestJS/TypeScript examples.

#abstraction #typescript #architecture #nestjs #anti-patterns

Wrong Abstractions: The Type Safety Trap

The Problem

In the NestJS ecosystem, developers tend to write interfaces like:

export interface Order {
  id: number
  totalPrice: Decimal
  createdAt: Date
  updatedAt: Date
}

And they simply place this interface in common/interfaces/order.

Then for UI purposes, they have to introduce other interfaces:

export interface OrderWithItems extends Order {
  items: { productId: number }[]
}

The Core Issue

So, what is wrong with these abstractions?

Let’s ask this: Why are they used? Are they here for abstracting a concept or implementation? No, they are here for just type safety.

This is a fundamental misunderstanding of what abstractions should be.

Why These Abstractions Are Wrong

1. They Don’t Abstract Behavior

These interfaces only describe data structure, not behavior or concepts. They’re essentially glorified type definitions.

2. They Create Artificial Coupling

By placing these in common/interfaces, you’re creating unnecessary dependencies between modules that shouldn’t know about each other. You are exposing the implementation details to other modules that should not be aware. When you separately implement order and item, you are telling others about implementation detail of database.

4. They Lead to Interface Explosion

You end up with multiple variations: Order, OrderWithItems, OrderSummary, OrderWithItemAndDiscount, etc.

What Abstractions Should Be

1. Domain Concepts

Abstractions should represent business concepts, not data structures.

export interface UserRepository {
  findById(id: UserId): Promise<User | null>
  save(user: User): Promise<void>
  findByEmail(email: Email): Promise<User | null>
}

export class User {
  constructor(
    private readonly id: UserId,
    private readonly email: Email,
    private readonly profile: UserProfile
  ) {}

  changeEmail(newEmail: Email): void {
    // Business logic here
  }

  isActive(): boolean {
    // Business logic here
  }
}

2. Service Contracts

Abstractions should define what services can do, not what data they contain.

export interface PaymentService {
  processPayment(amount: Money, method: PaymentMethod): Promise<PaymentResult>
  refundPayment(paymentId: PaymentId): Promise<RefundResult>
}


export interface NotificationService {
  sendEmail(to: EmailAddress, template: EmailTemplate): Promise<void>
  sendSms(to: PhoneNumber, message: string): Promise<void>
}

3. Ports and Adapters

Abstractions should define interfaces between your application and external systems.


export interface EmailProvider {
  send(template: EmailTemplate, recipient: EmailAddress): Promise<EmailResult>
}


export interface OrderRepository {
  save(order: Order): Promise<void>
  findById(id: OrderId): Promise<Order | null>
  findByCustomerId(customerId: CustomerId): Promise<Order[]>
}

Better Alternatives

1. Use Classes for Domain Entities

export class Order {
  constructor(
    private readonly id: OrderId,
    private readonly customerId: CustomerId,
    private readonly items: OrderItem[],
    private readonly status: OrderStatus
  ) {}

  addItem(item: OrderItem): void {
    // Business logic
  }

  calculateTotal(): Money {
    // Business logic
  }

  canBeCancelled(): boolean {
    // Business logic
  }
}

2. Use Value Objects

export class Email {
  constructor(private readonly value: string) {
    if (!this.isValid(value)) {
      throw new InvalidEmailError(value)
    }
  }

  private isValid(email: string): boolean {
    // Validation logic
  }

  toString(): string {
    return this.value
  }
}

export class Money {
  constructor(
    private readonly amount: number,
    private readonly currency: Currency
  ) {}

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new CurrencyMismatchError()
    }
    return new Money(this.amount + other.amount, this.currency)
  }
}

4. Use Types for Simple Data

export type OrderSummary = {
  id: string
  total: number
  status: string
  createdAt: Date
}

export type ApiError = {
  code: string
  message: string
  details?: Record<string, unknown>
}

Best Practices

1. Ask “Why?” Before Creating Interfaces

  • Are you abstracting behavior or just data structure?
  • Does this solve a real problem or just provide type safety?
  • Would this interface exist in a non-TypeScript codebase?
  • Would the abstraction change with change in the implementation?

2. Prefer Classes for Domain Logic

  • Use classes when you have behavior and state
  • Use value objects for domain concepts
  • Use DTOs for data transfer

3. Keep Interfaces Close to Their Usage

  • Don’t put everything in common/interfaces
  • Use dependency injection to provide implementations

Conclusion

The key insight is that abstractions should hide complexity and provide a clean interface to complex behavior, not just provide type safety for data structures.

When you find yourself creating interfaces that only describe data, ask yourself:

  • What behavior am I abstracting?
  • What complexity am I hiding?
  • What problem am I actually solving?

If the answer is “just type safety,” then you probably don’t need an interface - you need a type or a class with actual behavior.

Remember: Good abstractions make complex things simple. Bad abstractions make simple things complex.