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.
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.
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.
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.
// 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
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.
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
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.
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.
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.
// 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
// 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.
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.
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.
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.
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
- Lesson 11 is this material at length, including parameter properties and why Node refuses them.
- Lesson 03 covers structural typing, which is the idea to unlearn if you came from Java or C#.
- Lesson 09 is generic classes, and when a type parameter is not earning its keep.
- Lesson 04 is the union alternative to a class hierarchy.
- The OOP course itself, if you have not done it.