TypeScript Classes and 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.