Siddhant Deval
Siddhant Deval
system design17 min read

Contracts, Interfaces & Abstraction Hierarchies

Choosing between interfaces, type aliases, and abstract classes is not stylistic — it determines bundle footprint, dynamic dispatch overhead, and architectural flexibility at public system boundaries. This article establishes mechanical decision heuristics, covers TypeScript 5+ mixins, and shows how `noImplicitOverride` prevents silent contract breakage.

Series·Part 3 of 13

TypeScript Low-Level Design & Object-Oriented Architecture

Contracts, Interfaces & Abstraction Hierarchies

In TypeScript 5+, OOP is an architectural contract, not an inheritance tree: enforce domain invariants at compile time, encapsulate mutation strictly within aggregates, and invert dependencies so high-level business policy never couples to execution details. The word "contract" in that principle is precise — it refers to the interface declarations that define what a collaborator must provide, independent of how it provides it.

The choice between interface, type alias, and abstract class is frequently made by habit rather than engineering judgment. That default costs real money: choosing abstract class when an interface suffices adds JavaScript to the bundle, creates prototype chain entries in V8, and introduces single-inheritance constraints that limit architectural flexibility. This article builds mechanical decision heuristics so that choice is never accidental.


1. The Anti-Pattern Graveyard: The Diamond Inheritance Breakdown

Here is an abstraction hierarchy common in codebases that apply OOP patterns from Java directly to TypeScript without adapting to the structural type system:

TYPESCRIPT
// ❌ Anti-Pattern: 4-tier deep class hierarchy — every level adds coupling
class BaseEntity {
  abstract id: string;
  abstract createdAt: Date;
}

class AuditableEntity extends BaseEntity {
  abstract updatedAt: Date;
  abstract updatedBy: string;
}

class AccountEntity extends AuditableEntity {
  abstract accountType: 'CHECKING' | 'SAVINGS' | 'LEDGER';
  abstract balance: number;
}

class PremiumAccount extends AccountEntity {
  private interestMultiplier = 1.25;

  // To test PremiumAccount.calculateInterest(), you must instantiate
  // with FOUR layers of inherited abstract properties satisfied.
  // Mocking becomes a full constructor exercise, not a unit test.
  calculateInterest(rate: number): number {
    return this.balance * rate * this.interestMultiplier;
  }
}

The problem is not that the class hierarchy is deep — it is that each layer adds fields that a test must satisfy to instantiate the class at all. Unit testing calculateInterest should require only balance and interestMultiplier. Instead, it requires satisfying id, createdAt, updatedAt, updatedBy, accountType, and balance before the test can even begin. The inheritance hierarchy exported its construction burden to every test file.

The fix is a combination of interface composition and targeted abstract class usage: use interface for audit and identity contracts, use abstract class only for calculateInterest — the one piece that has shared implementation logic.


2. interface vs type vs abstract class: The Decision Matrix

2.1 Compile-Time Erasure vs. Runtime Emission

The most fundamental distinction: interface and type are TypeScript-only constructs that emit zero JavaScript. They exist only during type checking and are completely removed at compilation. abstract class and class are JavaScript constructs that emit executable prototype code that runs in V8.

TYPESCRIPT
// TypeScript source:
interface IPaymentGateway {
  charge(amount: number, currency: string): Promise<string>;
}

type PaymentResult = { status: 'OK'; transactionId: string } | { status: 'FAILED'; reason: string };

abstract class BaseProcessor {
  abstract process(amount: number): Promise<PaymentResult>;

  protected log(message: string): void {
    console.log(`[BaseProcessor] ${message}`);
  }
}
JAVASCRIPT
// ✅ Compiled JavaScript output:
// interface IPaymentGateway → NOTHING emitted (zero bytes)
// type PaymentResult        → NOTHING emitted (zero bytes)

// abstract class BaseProcessor → this IS emitted:
class BaseProcessor {
  log(message) {
    console.log(`[BaseProcessor] ${message}`);
  }
}

Every abstract class you declare adds a class definition to your bundle. At scale, across hundreds of files, this is measurable.

2.2 The Three-Way Decision Heuristic

Scenario Use
Public API contract — describes what external code must implement interface — erases to zero bytes, supports declaration merging
Union / intersection / mapped / conditional type — structural transformation type alias — richer type algebra than interface
Shared implementation — multiple concrete classes share actual logic (not just a shape) abstract class — justified because there is real code to inherit
Multiple inheritance of behavior Mixin functions (const Timestampable = <T extends Constructor>(Base: T)) — abstract class cannot extend multiple bases

2.3 Declaration Merging: The interface-Only Superpower

TYPESCRIPT
// interfaces can be augmented across module boundaries — type aliases cannot
interface IOrderRepository {
  findById(id: string): Promise<Order | null>;
}

// In another file / module — merges with the declaration above
interface IOrderRepository {
  findByCustomer(customerId: string): Promise<Order[]>;
}

// Implementors must now satisfy both method shapes
class PostgresOrderRepository implements IOrderRepository {
  findById(id: string): Promise<Order | null> { /* ... */ return Promise.resolve(null); }
  findByCustomer(customerId: string): Promise<Order[]> { /* ... */ return Promise.resolve([]); }
}

This is used by major libraries (Express, React types) to allow third-party packages to augment existing interfaces without modifying the original type definition. type aliases do not support merging — attempting to redeclare a type is an error.


3. Multiple Contract Implementation & Collision Safety

TypeScript allows a class to implement multiple interfaces simultaneously. When two interfaces declare methods with the same name, the class must provide a single implementation that is compatible with both signatures.

TYPESCRIPT
interface IOrderReader {
  findById(id: string): Promise<Order | null>;
}

interface ICachedReader {
  findById(id: string): Promise<Order | null>; // Same name, same signature — compatible
  invalidateCache(id: string): void;
}

// ✅ Single implementation satisfies both contracts
class CachedOrderRepository implements IOrderReader, ICachedReader {
  private cache = new Map<string, Order>();

  async findById(id: string): Promise<Order | null> {
    if (this.cache.has(id)) return this.cache.get(id)!;
    const order = await this.fetchFromDb(id);
    if (order) this.cache.set(id, order);
    return order;
  }

  invalidateCache(id: string): void {
    this.cache.delete(id);
  }

  private async fetchFromDb(id: string): Promise<Order | null> {
    return null; // Database query would go here
  }
}

When two interfaces declare the same method name with incompatible signatures, TypeScript issues a compile-time error at the implements clause, preventing the ambiguity from reaching runtime.


4. noImplicitOverride and Safe Inheritance Contracts

TypeScript 4.3 introduced the --noImplicitOverride compiler flag, which enforces that any method in a derived class that shadows a parent method must be decorated with the override keyword. This prevents a particularly insidious class of production bug: a base class method is renamed or removed, and the derived class silently stops overriding anything without any compiler warning.

4.1 Without noImplicitOverride

TYPESCRIPT
// tsconfig.json: "noImplicitOverride": false (dangerous default)

class BaseAuditLogger {
  logTransaction(txnId: string): void {
    console.log(`[AUDIT] txn: ${txnId}`);
  }
}

class ComplianceLogger extends BaseAuditLogger {
  // Intended to override logTransaction — but no 'override' keyword
  logTransaction(txnId: string): void {
    // Regulatory compliance logic
    console.log(`[COMPLIANCE] txn: ${txnId} — flagged for review`);
  }
}

// Base class renames the method to logEvent():
class BaseAuditLogger {
  logEvent(txnId: string): void { // ← renamed
    console.log(`[AUDIT] txn: ${txnId}`);
  }
}

// ComplianceLogger still has logTransaction() — now it overrides NOTHING
// The compliance logging silently disappears from the call path.
// No compiler error. This ships to production.

4.2 With noImplicitOverride + override

TYPESCRIPT
// tsconfig.json: "noImplicitOverride": true  ← recommended for all new projects

class ComplianceLogger extends BaseAuditLogger {
  override logTransaction(txnId: string): void { // ← explicit override
    console.log(`[COMPLIANCE] txn: ${txnId} — flagged for review`);
  }
}

// If base class renames logTransaction → logEvent:
// Immediately: TS Error: "This member cannot have an 'override' modifier because
// it is not declared in the base class 'BaseAuditLogger'"
// The bug is caught at the earliest possible moment.
Crucial Requirement

Enable "noImplicitOverride": true in tsconfig.json for all new TypeScript projects. For existing codebases, migrate gradually — add override to intended overrides, catch unintentional ones.

Two-column diagram. LEFT column labeled 'Without noImplicitOverride' in red: shows BaseAuditLogger with method 'logTransaction', ComplianceLogger extending it with 'logTransaction' (no override keyword). An arrow labeled 'Base class renamed to logEvent' points down. Below: ComplianceLogger still has 'logTransaction' but it overrides nothing. A red callout: 'Compliance logging silently removed — no compiler warning'. RIGHT column labeled 'With noImplicitOverride: true' in cyan: same scenario. ComplianceLogger has 'override logTransaction'. When base class renames to 'logEvent', a red error marker appears on 'override logTransaction' with tooltip: 'TS Error: Member not declared in base class'. A cyan callout: 'Bug surfaced at compile time — zero runtime cost'.
Two-column diagram. LEFT column labeled 'Without noImplicitOverride' in red: shows BaseAuditLogger with method 'logTransaction', ComplianceLogger extending i…

5. Abstract Classes: Template Method Pattern

When a class hierarchy genuinely shares implementation logic, abstract class is justified. The Template Method pattern defines an algorithmic skeleton in the abstract base and delegates specific steps to concrete subclasses.

TYPESCRIPT
// ✅ Abstract class is justified — shared algorithm, variant steps
abstract class PaymentProcessor {
  // Template Method — defines the immutable algorithm sequence
  async process(amount: number, currency: string): Promise<PaymentResult> {
    await this.validate(amount, currency);           // Step 1 — invariant
    const normalized = await this.normalize(amount); // Step 2 — variant
    const result = await this.charge(normalized);    // Step 3 — variant
    await this.audit(result);                        // Step 4 — invariant
    return result;
  }

  // Invariant steps — shared implementation, not overridable
  private async validate(amount: number, currency: string): Promise<void> {
    if (amount <= 0) throw new RangeError('Amount must be positive');
    if (!['USD', 'EUR', 'GBP'].includes(currency)) throw new Error('Unsupported currency');
  }

  private async audit(result: PaymentResult): Promise<void> {
    console.log(`[AUDIT] Payment result: ${JSON.stringify(result)}`);
  }

  // Variant steps — must be provided by concrete subclasses
  protected abstract normalize(amount: number): Promise<number>;
  protected abstract charge(normalizedAmount: number): Promise<PaymentResult>;
}

type PaymentResult = { status: 'OK'; transactionId: string } | { status: 'FAILED'; reason: string };

class StripeProcessor extends PaymentProcessor {
  protected async normalize(amount: number): Promise<number> {
    return Math.round(amount * 100); // Convert to cents for Stripe
  }

  protected async charge(normalizedAmount: number): Promise<PaymentResult> {
    // Stripe API call
    return { status: 'OK', transactionId: `pi_${Date.now()}` };
  }
}

class AdyenProcessor extends PaymentProcessor {
  protected async normalize(amount: number): Promise<number> {
    return amount; // Adyen accepts decimal amounts
  }

  protected async charge(normalizedAmount: number): Promise<PaymentResult> {
    // Adyen API call
    return { status: 'OK', transactionId: `adyen_${Date.now()}` };
  }
}

The abstract class is justified here because:

  1. Both StripeProcessor and AdyenProcessor must execute validate and audit identically.
  2. The process method orchestration logic is shared and cannot differ across implementations.
  3. There is genuine code inheritance — not just shape inheritance.

6. TypeScript 5+ Mixins: Composable Multi-Trait Classes

Class inheritance in TypeScript is single-chain: a class can only extend one base class. When multiple orthogonal capabilities must be composed — auditing, timestamping, soft-deletion — class mixins using higher-order factory functions provide a clean, multiple-inheritance-free solution.

6.1 Mixin Pattern Implementation

TYPESCRIPT
// Constructor type for generic mixin base
type Constructor<T = {}> = new (...args: any[]) => T;

// Timestampable mixin — adds createdAt / updatedAt tracking
function Timestampable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    readonly createdAt: Date = new Date();
    updatedAt: Date = new Date();

    touch(): void {
      this.updatedAt = new Date();
    }
  };
}

// Auditable mixin — adds actor tracking for compliance
function Auditable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    private auditLog: Array<{ actor: string; action: string; at: Date }> = [];

    recordAction(actor: string, action: string): void {
      this.auditLog.push({ actor, action, at: new Date() });
    }

    getAuditTrail(): ReadonlyArray<{ actor: string; action: string; at: Date }> {
      return this.auditLog;
    }
  };
}

// SoftDeletable mixin — logical deletion without database record removal
function SoftDeletable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    deletedAt: Date | null = null;

    get isDeleted(): boolean {
      return this.deletedAt !== null;
    }

    softDelete(): void {
      if (this.deletedAt) throw new Error('Already deleted');
      this.deletedAt = new Date();
    }
  };
}

// Base domain entity
class BaseEntity {
  constructor(readonly id: string) {}
}

// Compose capabilities via mixin chain
class Order extends SoftDeletable(Auditable(Timestampable(BaseEntity))) {
  private readonly #lineItems: string[] = [];

  addItem(item: string, actor: string): void {
    this.#lineItems.push(item);
    this.recordAction(actor, `ADD_ITEM:${item}`);
    this.touch();
  }
}

const order = new Order('ord_abc123');
order.addItem('Product A', 'usr_admin_001');
console.log(order.createdAt);         // Date — from Timestampable
console.log(order.getAuditTrail());   // [{actor: 'usr_admin_001', action: 'ADD_ITEM:Product A', ...}]
order.softDelete();
console.log(order.isDeleted);         // true — from SoftDeletable

6.2 Mixin Composition Diagram

Architectural Note

Cross-Language Rosetta — Mixins:

  • Go: Achieved via struct embedding. type Order struct { Timestampable; Auditable; ... }. Methods from embedded types are promoted without explicit delegation.
  • Python: Multiple inheritance with super() MRO (Method Resolution Order). class Order(SoftDeletable, Auditable, Timestampable, BaseEntity).
  • TypeScript: Mixin functions — no language-level multiple inheritance, but factory function composition produces equivalent semantics without diamond problem.

7. Recursive Generic Constraints

When a base class needs to return this-typed values that preserve the derived class's type, but must also express this in a generic signature, recursive constraints are the tool:

TYPESCRIPT
// T extends Entity<T> — T must be a subclass of Entity<T>
// This enables the base class to reference the concrete type of `this`
abstract class Entity<T extends Entity<T>> {
  protected abstract id(): string;

  // Base class method that returns the concrete derived type
  clone(): T {
    return Object.assign(Object.create(Object.getPrototypeOf(this)), this) as T;
  }

  equals(other: T): boolean {
    return this.id() === other.id();
  }
}

class LedgerEntry extends Entity<LedgerEntry> {
  constructor(
    private readonly entryId: string,
    readonly amount: number,
  ) {
    super();
  }

  protected id(): string {
    return this.entryId;
  }
}

const entry1 = new LedgerEntry('txn_001', 500);
const entry2 = entry1.clone(); // Type is LedgerEntry — not Entity<LedgerEntry>
const isSame = entry1.equals(entry2); // true

The recursive constraint T extends Entity<T> is the TypeScript idiom for "the self-referential type that represents the most-derived class." It is the compile-time equivalent of Class<? extends Self> generic bounds found in Rust and Swift.


Summary

Concept Rule
interface Zero bundle cost — use for public contracts, declaration merging, and structural shapes
type alias Use for unions, intersections, mapped/conditional types, and transforms
abstract class Non-zero bundle cost — justify only with shared implementation (Template Method)
noImplicitOverride Enable in every project; override keyword makes parent contract dependency explicit
Mixins Higher-order factory functions compose orthogonal capabilities without multiple-inheritance collisions
Declaration merging interface-only — allows third-party packages to extend existing types non-invasively
Recursive constraints T extends Class<T> captures the self-referential type of the most-derived class

What's Next

In Part 4, we move from defining contracts to managing the lifecycles of the objects that implement them. Composition vs. aggregation, circular module dependencies, memory leaks from zombie event listeners, and TypeScript 5.2+ using / Symbol.dispose for deterministic resource cleanup — the mechanics that keep a production fintech service from gradually degrading under load. Part 4: Object Lifecycles, Coupling & Domain Aggregates covers the full DDD aggregate model built on top of the foundations established in Parts 1–3.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#Interfaces#Abstract Classes#Mixins#Contracts#LLD Interview
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.