Object-Oriented Design in TypeScript: Principles and Practice

Understand the core object-oriented design ideas — encapsulation, abstraction, inheritance, polymorphism, and composition — how TypeScript's interfaces support dependency inversion, and how these ideas connect to the SOLID principles at a practical, applied level.

Object-oriented design is a set of ideas about organizing code around objects that bundle data and behavior together, structured so that change in one part of a system doesn't ripple uncontrollably through the rest. TypeScript's class and interface features are the mechanics; this lesson is about the design judgment behind using them well.

Encapsulation and abstraction

Encapsulation means bundling an object's data with the methods that operate on it, and restricting direct access to that data from outside — exactly what private/# fields plus public methods achieve mechanically. The goal isn't secrecy for its own sake; it's controlling how state can change, so an object can maintain its own internal invariants (a bank account's balance never going negative, say) no matter what external code does.

Abstraction means exposing a simplified, purpose-focused interface while hiding implementation detail behind it. A PaymentProcessor interface with a charge(amount: number): Promise<Receipt> method lets calling code depend on 'the ability to charge a payment' without knowing or caring whether the concrete implementation calls Stripe, PayPal, or a mock for testing. Abstraction and encapsulation work together: encapsulation hides data, abstraction hides complexity, and both reduce how much a caller needs to know to use an object correctly.

Inheritance and polymorphism

Inheritance (extends) lets a subclass reuse and specialize a parent class's behavior. Polymorphism is the ability to treat objects of different concrete types uniformly through a shared interface or base class — a function that accepts a Shape can operate on a Circle, Square, or Rectangle interchangeably, calling area() on each without knowing which concrete type it actually received, as long as each subclass correctly implements the shared contract.

Polymorphism doesn't require inheritance specifically — TypeScript's structural typing means any two objects with a compatible method signature are polymorphically interchangeable even with no shared base class at all. This is sometimes called 'interface-based' or 'structural' polymorphism, and it's arguably more central to idiomatic TypeScript design than class-hierarchy-based polymorphism, precisely because TypeScript's type system rewards shape compatibility over declared ancestry.

Composition vs inheritance

Composition means building complex objects by combining simpler ones as properties, rather than by inheriting from a shared base class. A Car that has an Engine and has Wheels (composition — a 'has-a' relationship) is generally more flexible than modeling Car as some elaborate inheritance chain, because the Engine and Wheels can be swapped, tested, and reused independently of Car and of each other.

Deep inheritance hierarchies tend to become fragile: changing a base class can unpredictably affect every subclass several levels down, and a subclass is often forced to inherit behavior it doesn't actually want just because it needs one other piece the parent provides. The long-standing design guidance 'favor composition over inheritance' reflects this: reach for inheritance only when there's a genuine, stable 'is-a' relationship with shared behavior that really belongs in a common ancestor, and reach for composition (has-a relationships, small focused pieces combined together) for most everything else.

In TypeScript specifically, composition is also naturally supported by interfaces and function types as properties — a class can hold a private logger: Logger and a private validator: Validator as constructor-injected dependencies, composing behavior from smaller, independently testable pieces rather than inheriting it.

Interfaces, dependency inversion, and a brief look at SOLID

Dependency inversion means high-level code should depend on abstractions (interfaces), not on concrete low-level implementations — and that concrete implementations should depend on those same abstractions too, rather than the abstraction depending on the implementation. In TypeScript, this typically looks like a class depending on an interface type for something it needs (constructor(private db: Database) where Database is an interface), with the concrete implementation (a real Postgres connection, or a test double) supplied from outside — often called constructor injection. This is what makes swapping a real database for a test mock straightforward without touching the class's own code.

Dependency inversion is the 'D' in SOLID, a set of five object-oriented design principles: Single Responsibility (a class should have one reason to change), Open/Closed (open for extension, closed for modification — add new behavior by adding new code, not editing existing working code), Liskov Substitution (a subclass should be usable anywhere its parent type is expected without breaking correctness), Interface Segregation (prefer several small, focused interfaces over one large one that forces implementers to support methods they don't need), and Dependency Inversion (as above). This lesson introduces them briefly as a shared vocabulary for design discussions; a dedicated lesson on applying all five in a larger application comes later, in this site's React-focused module.

The practical takeaway across all of this: TypeScript's structural typing, interfaces, and access modifiers are just tools. Good object-oriented design in TypeScript is less about using every feature (inheritance, abstract classes, decorators) and more about consistently asking 'what does this piece need to know about that piece' and shrinking the answer as much as the problem allows — favoring small interfaces, composed pieces, and dependencies expressed as abstractions rather than concrete implementations wherever it's practical to do so.

Dependency inversion via an interface

OrderService depends on the Logger interface, not on ConsoleLogger specifically — any object with a compatible log method can be passed in instead, including a test double. This is constructor injection: the dependency is supplied from outside rather than created inside the class, which is what makes the dependency swappable and the class independently testable.

interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}

class OrderService {
  constructor(private logger: Logger) {}

  placeOrder(id: number): void {
    this.logger.log(`Order ${id} placed`);
  }
}

const service = new OrderService(new ConsoleLogger());
service.placeOrder(101);

Code Examples

Car doesn't inherit from GasEngine or ElectricEngine — it holds an Engine (any implementation) as a composed dependency, so swapping engine types requires no change to Car itself at all, just a different object passed to its constructor. Expected result: Vroom - driving off Silent hum - driving off

totalArea treats every Shape uniformly through the shared area() method, without needing to know whether it received a Circle or a Square — this is polymorphism driven entirely by the shared interface, not by a shared base class. Expected result: 21.566370614359172

Common mistake: Reaching for a deep inheritance chain (e.g. AbstractShape -> AbstractPolygon -> Square) here would add rigidity without adding any real benefit — the interface alone gives full polymorphic behavior with far less coupling between the shape classes.

interface Engine {
  start(): string;
}

class GasEngine implements Engine {
  start(): string {
    return "Vroom";
  }
}

class ElectricEngine implements Engine {
  start(): string {
    return "Silent hum";
  }
}

class Car {
  constructor(private engine: Engine) {}

  drive(): string {
    return `${this.engine.start()} - driving off`;
  }
}

const gasCar = new Car(new GasEngine());
const electricCar = new Car(new ElectricEngine());
console.log(gasCar.drive());
console.log(electricCar.drive());
interface Shape {
  area(): number;
}

class Circle implements Shape {
  constructor(private radius: number) {}
  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Square implements Shape {
  constructor(private side: number) {}
  area(): number {
    return this.side ** 2;
  }
}

function totalArea(shapes: Shape[]): number {
  return shapes.reduce((sum, s) => sum + s.area(), 0);
}

console.log(totalArea([new Circle(2), new Square(3)]));

Composition (has-a) vs inheritance (is-a)

Composition lets Car depend on the Engine abstraction and swap concrete engines freely, without any inheritance relationship between Car and the engine types at all.

Inheritance (is-a):        Composition (has-a):
  Vehicle                     Car
    ^                          |
    |                          | has an
  Car                        Engine (interface)
                               ^        ^
                               |        |
                          GasEngine  ElectricEngine

Important Rules

Favor composition (has-a, built from smaller composed pieces) over deep inheritance chains for most relationships; reserve inheritance for genuine, stable is-a relationships with real shared behavior.

Depend on interfaces (abstractions) rather than concrete classes wherever a dependency might reasonably need to be swapped, mocked, or replaced — this is dependency inversion in practice.

Encapsulate state behind methods that preserve an object's own invariants; don't expose raw mutable fields that let external code put the object into an inconsistent state.

Prefer several small, focused interfaces over one large interface that forces every implementer to support methods it doesn't actually need (interface segregation).

Where these principles pay off

Backend services almost universally depend on interfaces for things like database access, logging, and external API clients specifically so unit tests can supply lightweight fakes instead of real infrastructure; frontend component libraries favor composition (small components combined together) over deep class hierarchies for exactly the same reason — smaller, independently swappable pieces are easier to test, reuse, and reason about than one large, tightly coupled hierarchy.

class OrderService { private logger = new ConsoleLogger(); // OrderService is now hard-wired to ConsoleLogger specifically } class OrderService { constructor(private logger: Logger) {} // any Logger implementation can be supplied from outside }

Constructing the dependency directly inside the class ties OrderService to one concrete implementation, making it impossible to substitute a test double or an alternative logger without editing OrderService's own source.

[multiple-choice] What does 'favor composition over inheritance' recommend in practice? A. Never use classes at all B. Build complex behavior by combining smaller composed objects (has-a) rather than deep inheritance chains (is-a), reserving inheritance for genuine shared-behavior relationships C. Always use abstract classes instead of interfaces D. Avoid interfaces entirely

B. Build complex behavior by combining smaller composed objects (has-a) rather than deep inheritance chains (is-a), reserving inheritance for genuine shared-behavior relationships — Composition tends to produce more flexible, less fragile designs than deep inheritance hierarchies, because composed pieces can be swapped and tested independently, so it's generally preferred except where a genuine is-a relationship with shared behavior exists.

[multiple-choice] What does dependency inversion mean in a TypeScript class like class OrderService { constructor(private logger: Logger) {} } where Logger is an interface? A. OrderService creates its own ConsoleLogger internally B. OrderService depends on the Logger abstraction, not a concrete implementation, so the actual implementation is supplied from outside and can be swapped C. Logger depends on OrderService D. It has no special meaning here

B. OrderService depends on the Logger abstraction, not a concrete implementation, so the actual implementation is supplied from outside and can be swapped — Dependency inversion means high-level code depends on an abstraction rather than a concrete implementation; here OrderService only knows about the Logger interface, and any compatible implementation can be injected.

[true-false] In TypeScript, two classes must share a common base class via inheritance in order to be used polymorphically through the same function. A. True B. False

B. False — Because TypeScript is structurally typed, any two classes implementing a compatible method (like a shared area() signature) can be treated polymorphically through that shared shape, with no common base class required at all.

Define an interface Notifier with a send(message: string): void method. Write two implementations, EmailNotifier and SmsNotifier, each logging a different prefix. Write a class AlertService that takes a Notifier via constructor injection and exposes alert(message: string): void that calls send on it. Instantiate AlertService with each notifier and call alert.

interface Notifier { send(message: string): void; } class EmailNotifier implements Notifier { send(message: string): void { console.log([Email] ${message}); } } class SmsNotifier implements Notifier { send(message: string): void { console.log([SMS] ${message}); } } class AlertService { constructor(private notifier: Notifier) {} alert(message: string): void { this.notifier.send(message); } } new AlertService(new EmailNotifier()).alert("Server down"); new AlertService(new SmsNotifier()).alert("Server down");

AlertService should depend only on the Notifier interface type, never on EmailNotifier or SmsNotifier directly.