Emmanuel Akinfulubi

OOP, typed

The patterns from the object-oriented JavaScript course, now checked. Everything you learned still holds; what changes is that the contracts you were keeping in your head are enforced.

There is also one genuine addition. JavaScript had no way to declare that a class satisfies an interface, so “program to an interface” was advice you followed by discipline. TypeScript makes it a checked claim, and that is the largest single thing this language adds to object-oriented code.

The two kinds of private

The awkward one. TypeScript's private keyword is a compile-time annotation. JavaScript's # prefix is a runtime feature. They look interchangeable.

The two kinds of private, side by side

class Account {
  private balanceTs = 0;     // TypeScript private: checked, then erased
  #balanceJs = 0;            // JavaScript private: real at run time

  deposit(amount: number): void {
    this.balanceTs += amount;
    this.#balanceJs += amount;
  }

  get balance(): number {
    return this.#balanceJs;
  }
}

const account = new Account();
account.deposit(100);

console.log('balance:', account.balance);

// The TypeScript private is gone after compilation, so anything that reaches
// the object without type checking can read it.
const loose = account as unknown as Record<string, unknown>;
console.log('balanceTs is reachable:', loose['balanceTs']);

// The JavaScript private genuinely is not there.
console.log('own properties:', Object.getOwnPropertyNames(account));
console.log('JSON sees one field:', JSON.stringify(account));
What this compiles to
class Account {
    balanceTs = 0; // TypeScript private: checked, then erased
    #balanceJs = 0; // JavaScript private: real at run time
    deposit(amount) {
        this.balanceTs += amount;
        this.#balanceJs += amount;
    }
    get balance() {
        return this.#balanceJs;
    }
}
const account = new Account();
account.deposit(100);
console.log('balance:', account.balance);
// The TypeScript private is gone after compilation, so anything that reaches
// the object without type checking can read it.
const loose = account;
console.log('balanceTs is reachable:', loose['balanceTs']);
// The JavaScript private genuinely is not there.
console.log('own properties:', Object.getOwnPropertyNames(account));
console.log('JSON sees one field:', JSON.stringify(account));

Output


      

Both are compile errors if you reach for them from outside.

Both are compile errors, for what that is worth

TS2341
class Account {
  private balanceTs = 0;
  #balanceJs = 0;

  deposit(amount: number): void {
    this.balanceTs += amount;
    this.#balanceJs += amount;
  }
}

const account = new Account();
account.deposit(10);

console.log(account.balanceTs);

What the compiler says

error TS2341: Property 'balanceTs' is private and only accessible within class 'Account'.

And one thing # can do that nothing else in the language can. A private field cannot be replicated by an object literal, so it makes a class refuse structural lookalikes — effectively giving you nominal typing where you need it.

And the thing only # can do

TS2741
class Token {
  // A private field cannot be replicated by an object literal, which makes
  // this class refuse structural lookalikes.
  #brand = true;

  constructor(public readonly value: string) {}
}

function authorise(token: Token): string {
  return token.value;
}

// Structural typing would normally accept this. The #brand stops it.
console.log(authorise({ value: 'forged' }));

What the compiler says

error TS2741: Property '#brand' is missing in type '{ value: string; }' but required in type 'Token'.

Interfaces, and the claim that is now checked

The real addition. In JavaScript, “this class implements Storage” was a comment. Here it is a claim the compiler verifies, and the error lands on the class rather than at every place the class is used.

Program to an interface, now enforced

// The contract. No implementation, and it disappears at compile time.
interface Storage2 {
  readonly name: string;
  save(key: string, value: string): void;
  load(key: string): string | undefined;
}

class MemoryStorage implements Storage2 {
  readonly name = 'memory';
  #data = new Map<string, string>();

  save(key: string, value: string): void {
    this.#data.set(key, value);
  }

  load(key: string): string | undefined {
    return this.#data.get(key);
  }
}

class PrefixedStorage implements Storage2 {
  readonly name = 'prefixed';
  #inner: Storage2;
  #prefix: string;

  constructor(inner: Storage2, prefix: string) {
    this.#inner = inner;
    this.#prefix = prefix;
  }

  save(key: string, value: string): void {
    this.#inner.save(this.#prefix + key, value);
  }

  load(key: string): string | undefined {
    return this.#inner.load(this.#prefix + key);
  }
}

// The calling code depends on the interface, never on either class. This is
// the same advice the OOP course gave; the difference is that the compiler
// now checks the classes actually honour it.
function roundTrip(storage: Storage2, key: string, value: string): string {
  storage.save(key, value);
  return `[${storage.name}] ${key} -> ${storage.load(key) ?? 'missing'}`;
}

const memory = new MemoryStorage();
const prefixed = new PrefixedStorage(memory, 'app:');

console.log(roundTrip(memory, 'theme', 'dark'));
console.log(roundTrip(prefixed, 'theme', 'light'));
console.log('the prefix is visible underneath:', memory.load('app:theme'));

Output


      

A class that does not deliver

TS2420
interface Storage2 {
  readonly name: string;
  save(key: string, value: string): void;
}

// No 'name' property, so the class as a whole is reported.
class Broken implements Storage2 {
  save(key: string, value: string): void {
    console.log(key, value);
  }
}

new Broken().save('a', 'b');

What the compiler says

error TS2420: Class 'Broken' incorrectly implements interface 'Storage2'.

Inheritance and polymorphism

Unchanged in behaviour, and now with two guarantees: every abstract member is supplied, and every override is deliberate.

Polymorphism, with exhaustiveness

abstract class Shape {
  constructor(public readonly label: string) {}

  // Every subclass must supply this.
  abstract area(): number;

  // Shared, and written once.
  describe(): string {
    return `${this.label}: ${this.area().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(private readonly radius: number) {
    super('circle');
  }

  override area(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Rectangle extends Shape {
  constructor(
    private readonly width: number,
    private readonly height: number,
  ) {
    super('rectangle');
  }

  override area(): number {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(side: number) {
    super(side, side);
  }
}

// One loop, three types, each answering in its own way. Exactly as before —
// except the compiler now guarantees every subclass has an area().
const shapes: readonly Shape[] = [new Circle(1), new Rectangle(3, 4), new Square(2)];
for (const shape of shapes) console.log(shape.describe());

console.log('a Square is a Rectangle:', shapes[2] instanceof Rectangle);

Output


      

A subclass that forgot

TS2515
abstract class Shape {
  abstract area(): number;
  abstract perimeter(): number;
}

class Triangle extends Shape {
  constructor(private readonly base: number, private readonly height: number) {
    super();
  }

  override area(): number {
    return (this.base * this.height) / 2;
  }
}

console.log(new Triangle(3, 4).area());

What the compiler says

error TS2515: Non-abstract class 'Triangle' does not implement inherited abstract member perimeter from class 'Shape'.

And the bug noImplicitOverride exists for. Rename a method in a base class and the subclass version becomes dead code that still compiles and never runs. With override required, it does not compile.

The rename bug, caught

TS4113
class Repository {
  // Imagine this was called findById last week and somebody renamed it.
  findOne(id: number): string {
    return `row ${id}`;
  }
}

class CachedRepository extends Repository {
  // This used to override something. Now it is dead code that nothing calls,
  // and without the override keyword nothing would have told you.
  override findById(id: number): string {
    return `cached ${id}`;
  }
}

console.log(new CachedRepository().findOne(1));

What the compiler says

error TS4113: This member cannot have an 'override' modifier because it is not declared in the base class 'Repository'.

Generic classes

A container that works over any type, with the constraint stating what it needs. Note add(): this rather than add(): Repository<T> — that is what lets a chain survive subclassing.

A generic class, and returning this

interface Identified {
  readonly id: number;
}

class Repository<T extends Identified> {
  #items = new Map<number, T>();

  add(item: T): this {
    this.#items.set(item.id, item);
    return this;                    // 'this', not Repository<T>
  }

  find(id: number): T | undefined {
    return this.#items.get(id);
  }

  all(): readonly T[] {
    return [...this.#items.values()];
  }

  get size(): number {
    return this.#items.size;
  }
}

interface Product extends Identified {
  sku: string;
  price: number;
}

class ProductRepository extends Repository<Product> {
  cheaperThan(limit: number): readonly Product[] {
    return this.all().filter((product) => product.price < limit);
  }
}

// Because add() returns 'this', the chain remembers it is a
// ProductRepository, so cheaperThan() is still available afterwards.
const products = new ProductRepository()
  .add({ id: 1, sku: 'desk', price: 250 })
  .add({ id: 2, sku: 'chair', price: 90 })
  .add({ id: 3, sku: 'lamp', price: 40 });

console.log('size:      ', products.size);
console.log('find 2:    ', products.find(2)?.sku);
console.log('under 100: ', products.cheaperThan(100).map((p) => p.sku));

Output


      

Composition, and why the types make the case

The OOP course spent two lessons arguing for composition over inheritance. TypeScript makes the argument legible: a composed class states its dependencies in its constructor signature, so you can read everything it touches without opening the body.

Composition over inheritance, and the type that makes it obvious

// The OOP course argued for composition. TypeScript makes the argument
// visible, because the composed version's types say exactly what it needs.

interface Clock {
  now(): Date;
}

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

// This class states its two dependencies in its constructor. You can read the
// whole of what it touches from the signature.
class OrderService {
  #clock: Clock;
  #logger: Logger;

  constructor(clock: Clock, logger: Logger) {
    this.#clock = clock;
    this.#logger = logger;
  }

  place(id: string): string {
    const at = this.#clock.now().toISOString().slice(0, 10);
    this.#logger.log(`order ${id} placed on ${at}`);
    return `${id}@${at}`;
  }
}

// Swapping either one is a matter of passing something else. No subclassing,
// no protected members, no fragile base class.
const lines: string[] = [];

const service = new OrderService(
  { now: () => new Date('2026-03-01T00:00:00Z') },
  { log: (message) => lines.push(message) },
);

console.log(service.place('A-1001'));
console.log('captured log:', lines);

// And a real clock, same class.
const live = new OrderService({ now: () => new Date() }, { log: () => {} });
console.log('with a live clock:', live.place('A-1002').split('@')[0]);

Output


      

The four pillars, with the check each one gains

The four pillars, each with the check TypeScript adds

// 1. ENCAPSULATION — # makes it real, not merely documented.
class Counter {
  #count = 0;
  increment(): number {
    return ++this.#count;
  }
}

// 2. ABSTRACTION — the interface is the whole contract; the class is detail.
interface Sink {
  write(line: string): void;
}

// 3. INHERITANCE — abstract members must be supplied, overrides must be declared.
abstract class Formatter {
  abstract format(value: unknown): string;
  emit(value: unknown, sink: Sink): void {
    sink.write(this.format(value));
  }
}

class JsonFormatter extends Formatter {
  override format(value: unknown): string {
    return JSON.stringify(value);
  }
}

class PlainFormatter extends Formatter {
  override format(value: unknown): string {
    return String(value);
  }
}

// 4. POLYMORPHISM — one call site, several answers, and substitution checked.
const captured: string[] = [];
const sink: Sink = { write: (line) => captured.push(line) };

const formatters: readonly Formatter[] = [new JsonFormatter(), new PlainFormatter()];
for (const formatter of formatters) formatter.emit({ id: 1 }, sink);

const counter = new Counter();
counter.increment();

console.log('encapsulation:', counter.increment());
console.log('polymorphism: ', captured);
console.log('substitution is checked at compile time, not hoped for at run time');

Output


      

Encapsulation

Was a convention with _underscore names, then became real with #. TypeScript adds compile-time checking on top, and a second weaker private that is worth knowing about mainly so you can avoid confusing it with the first.

Lesson 11

Abstraction

The biggest gain. An interface is a first-class thing you can name, pass around, derive from and check against — and it costs nothing at run time because it is erased entirely.

Lesson 03

Inheritance

abstract members must be supplied and override must be declared, so the two most common inheritance bugs become compile errors. The advice to prefer composition is unchanged; the compiler just makes the alternative easier to read.

Lesson 11

Polymorphism

Substitution is checked rather than hoped for. And for the cases where a class hierarchy was overkill, a discriminated union gives you the same dispatch with exhaustiveness checking and no inheritance at all.

Lesson 04

One thing to reconsider

A class hierarchy is not always the right answer, and TypeScript gives you a genuine alternative the OOP course could not. Where you would have written a base class with three subclasses purely to get polymorphic dispatch, a discriminated union plus a switch does the same job with less machinery — and gets you exhaustiveness checking, so adding a fourth variant produces a compiler-generated list of everywhere you need to visit.

Use classes when there is real shared state and behaviour. Use a union when you are only dispatching on a tag. That distinction is easier to act on in TypeScript than it was in JavaScript, because the union version is now genuinely safe.

Where to read more

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.