TypeScript Classes and Encapsulation

JavaScript already has classes — so what does TypeScript add? Access modifiers that become real encapsulation (private members can be truly hidden in the emit), checked implements contracts, and a constructor shorthand that removes a third of the boilerplate. Equally important: knowing when a plain object type is the better tool.

When a class is the right tool

A class earns its keep when data and invariant must stay together: a Money that can never hold negative cents, a Session that cannot be used after expiry. If your value is a bag of fields without behavior — most API payloads — use an interface and plain objects; the previous lesson covered why.

The invariant-keeping class

Purpose of the example: model a counter whose value can never go negative — a guarantee no plain object type can enforce.

class Counter {
  private value = 0;          // hidden member — see "one surprise" below

  increment(step = 1): void {
    this.value += step;
  }

  get current(): number {     // read-only view of the hidden state
    return this.value;
  }
}

const c = new Counter();
c.increment(); c.increment(2);
console.log(c.current);   // 3
// c.value = -10;         // blocked — see the erasure caveat below

What you should see: external code can only read via current and mutate via increment; the class alone decides what transitions are legal. That is the encapsulation argument for classes.

Access modifiers and the one surprise

TypeScript's modifiers — public (default), private, protected, plus readonly — are checked at compile time. But they interact with type erasure in a way that matters for what you ship.

Compile-time vs runtime privacy

By default, TypeScript's private is erased: the emitted JavaScript keeps the property, readable by anyone. There is a second spelling — ECMAScript's #private fields — that the runtime itself enforces:

class Card {
  private pin = "1234";     // design-time private; visible in the JS emit
  #secret = "hash";         // runtime private — inaccessible even from JS
  get pinHint(): string { return `***${this.pin.slice(-1)}`; }
}

const card = new Card();
// card.pin            // compile error: Property 'pin' is private
console.log((card as any).pin);   // "1234" — TypeScript privacy is advisory!
// card.#secret        // syntax error even in plain JavaScript

What you should see: the as any escape reads a private field without complaint at runtime, while #secret is genuinely unreachable. What we learn: choose private for API design intent (normal case); choose #fields when the value must be hidden for security, not just tidiness.

Parameter properties: the shorthand

Writing private readonly x: number as a constructor parameter declares, types, and assigns the field in one move — TypeScript's most visible ergonomic win over JavaScript classes:

Purpose of the example: compare the JavaScript boilerplate with the TypeScript shorthand.

class Money {
  // these two parameters create and initialize two readonly fields:
  constructor(
    private readonly cents: number,
    private readonly currency: "USD" | "EUR",
  ) {}

  format(): string {
    return `${this.currency} ${(this.cents / 100).toFixed(2)}`;
  }
}

const price = new Money(1299, "EUR").format();
console.log(price);   // "EUR 12.99"

What you should see: the same class in plain JavaScript needs field declarations plus two assignment lines (this.cents = cents; …). What we learn: parameter properties eliminate the "declare, then assign" ritual; note they are transformed syntax — one of the constructs Node's native stripping cannot run (setup lesson), fine everywhere else.

implements and structural surprises

implements promises the checker that a class satisfies an interface. Because checking is structural, the promise behaves in ways Java/C# developers find surprising — and those surprises cut both ways.

implements is a claim, not a constraint

Purpose of the example: show that classes remain structurally typed even when they implement an interface.

interface Greeter { greet(name: string): string }

class Formal implements Greeter {
  greet(name: string): string { return `Good day, ${name}.`; }
  farewell(name: string): string { return `Farewell, ${name}.`; }  // extra: fine
}

const g: Greeter = new Formal();          // OK — structural superset
const anyObj = { greet: (n: string) => `Hi ${n}` };
const g2: Greeter = anyObj;               // OK too — no class required!
console.log(g.greet("Ada"), g2.greet("Grace"));

What you should see: both assignments pass. A plain object literal satisfies the interface just as well as the class does. What we learn: implements only checks the class against the interface; it does not make the type nominal. The flip side — the second surprise — is that extra public members keep a class assignable, but private/protected members do make two structurally identical classes incompatible: privacy participates in structural identity.

Abstract classes: partway instances

When subclasses must fill in behavior the base class depends on, an abstract class documents and enforces that half-implemented state:

abstract class Repository {
  abstract getById(id: string): Promise;

  // concrete method using the abstract one — subclasses inherit it:
  async exists(id: string): Promise {
    return (await this.getById(id)) !== null;
  }
}

class UserRepo extends Repository<{ id: string }> {
  async getById(id: string) {
    return id === "u_1" ? { id } : null;   // stub: real code queries a DB
  }
}

console.log(await new UserRepo().exists("u_1")); // true

What you should see: new Repository(...) is a compile error — abstract classes cannot be instantiated; the checker forces missing methods to exist. What we learn: abstract classes combine a contract with shared implementation; prefer plain interfaces when no shared code exists.

Composition over inheritance in practice

TypeScript's structural system weakens the traditional argument for deep hierarchies: since any shape-compatible object substitutes for a class, you rarely need five levels of base classes. The professional default in 2026 codebases: shallow classes (often one level), override keyword mandatory (noImplicitOverride from the setup lesson), and capabilities delivered by injecting collaborators rather than inheriting them.

Why structural typing changes the trade-off

In nominal languages, substitutability requires inheritance, so reuse grows hierarchies. In TypeScript, substitutability requires only shape agreement — a plain object literal can stand in for a class instance. When the "is-a" tax is gone, so is the main reason to pay for deep inheritance.

Decorator pattern: composition in action

Purpose of the example: compare a hierarchy extension with a composition extension when requirements change.

// Requirement change: "also log every greet call"
// Inheritance solution: touch the hierarchy
class LoggingGreeter extends Formal {
  override greet(name: string): string {
    console.log(`greet(${name})`);
    return super.greet(name);
  }
}

// Composition solution: wrap any Greeter, no hierarchy knowledge
class LoggingGreeter2 implements Greeter {
  constructor(private inner: Greeter) {}
  greet(name: string): string {
    console.log(`greet(${name})`);
    return this.inner.greet(name);
  }
}

const decorated = new LoggingGreeter2(new Formal());
console.log(decorated.greet("Ada"));   // logs, then "Good day, Ada."

What you should see: both work here; the composed version works for any Greeter — plain objects included — while the inheritance version is welded to Formal. What we learn: structural typing makes the decorator pattern nearly free. This is the strongest practical argument for composition in TypeScript.

Testing and anti-patterns

Test behavior, not structure

Because of structural typing, test doubles need no mocking framework: a hand-written object with the right shape substitutes for the real collaborator. Test public behavior and invariants (format(), exists()); tests reaching into private state lock in implementation and rot fastest — and the checker will tell you when you try.

The three class anti-patterns

Purpose of the example: recognize the defects in the wild:

// 1. God class — unrelated concerns in one stateful bag:
class AppManager {
  private users: User[] = [];
  private cache = new Map();
  sendEmail(to: string): void { /* … */ }
  renderReport(): string { /* … */ }   // four jobs, zero cohesion
}

// 2. Public mutable fields — invariants impossible:
class BadSession { expiresAt: Date = new Date(); } // anyone can un-expire it

// 3. Side effects in getters — reads that mutate:
class Tricky {
  private loaded = false;
  get data(): string {
    if (!this.loaded) this.loaded = true;  // ← mutation inside a read!
    return "payload";
  }
}

What we learn: each anti-pattern breaks the reason you chose a class: cohesion, invariant, or predictable reads. A getter must be repeatable without consequence; state changes belong in methods with intent-revealing names.

Practice: model a session

Session with timeout rules

Build a SessionState class: private lastSeen: Date, a touch() method, a get expired getter comparing against a 30-minute timeout, and no public field mutation possible. Write three checks: fresh session not expired, expired after simulated time, touch() resetting the clock. What you should see: the class making invalid states unrepresentable — no caller can construct an expired-but-active session through the public API.

Replace inheritance with a decorator

Refactor the LoggingGreeter above into a composing wrapper implementing the same interface. What you should see: the wrapper compiles against any conforming implementation, and the inheritance edge cases (base renames, constructor chaining) simply disappear. Then continue to the generics lesson, which makes Repository<T> fully general.