Emmanuel Akinfulubi

Lesson 11 of 1819 min read

Classes

Everything from the OOP course, now checkable. Including the awkward truth that TypeScript has two different kinds of private and only one of them is real.

By the end: Type a class hierarchy, and choose between private and #.

If you did the object-oriented JavaScript course, this lesson is that material with the checking switched on. Everything you learned about constructors, prototypes, inheritance and encapsulation still holds exactly. What changes is that the compiler now enforces the contracts you were previously keeping in your head.

There is also one genuinely awkward thing to get straight, and it is best dealt with early: TypeScript has two different kinds of private, and only one of them is real.

Fields, and the two ways to declare them

Declared fields, and the shorthand

// The explicit form: declare the fields, assign them in the constructor.
class Explicit {
  name: string;
  age: number;
  active = true;              // initialiser, so the type is inferred

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

// Parameter properties: the modifier on the parameter declares AND assigns
// the field. Less typing, and one of the few places TypeScript emits code
// that is not simply erased.
class Shorthand {
  active = true;

  constructor(
    public name: string,
    public age: number,
  ) {}
}

const a = new Explicit('Ada', 36);
const b = new Shorthand('Grace', 45);

console.log(a.name, a.age, a.active);
console.log(b.name, b.age, b.active);

Output


      

What the shorthand actually emits

class Point {
  constructor(
    public x: number,
    public y: number,
  ) {}

  distanceFromOrigin(): number {
    return Math.sqrt(this.x ** 2 + this.y ** 2);
  }
}

const p = new Point(3, 4);
console.log(p.x, p.y, p.distanceFromOrigin());
What this compiles to
class Point {
    x;
    y;
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    distanceFromOrigin() {
        return Math.sqrt(this.x ** 2 + this.y ** 2);
    }
}
const p = new Point(3, 4);
console.log(p.x, p.y, p.distanceFromOrigin());

Output


      

The two kinds of private

This is the part worth getting right. TypeScript's private keyword is a compile-time annotation. JavaScript's # prefix is a runtime feature. They look interchangeable and behave very differently.

private is checked and then thrown away

class Account {
  private balanceTs = 0;      // TypeScript private
  #balanceJs = 0;             // JavaScript private

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

  report(): string {
    return `ts=${this.balanceTs} js=${this.#balanceJs}`;
  }
}

const account = new Account();
account.deposit(100);
console.log(account.report());

// The TypeScript private is gone at run time, so it is reachable from
// JavaScript that does not know it was supposed to be off limits.
const asAny = account as unknown as Record<string, unknown>;
console.log('balanceTs is visible:', asAny['balanceTs']);

// The JavaScript private genuinely is not there.
console.log('own property names:', Object.getOwnPropertyNames(account));
console.log('JSON sees only one of them:', JSON.stringify(account));
What this compiles to
class Account {
    balanceTs = 0; // TypeScript private
    #balanceJs = 0; // JavaScript private
    deposit(amount) {
        this.balanceTs += amount;
        this.#balanceJs += amount;
    }
    report() {
        return `ts=${this.balanceTs} js=${this.#balanceJs}`;
    }
}
const account = new Account();
account.deposit(100);
console.log(account.report());
// The TypeScript private is gone at run time, so it is reachable from
// JavaScript that does not know it was supposed to be off limits.
const asAny = account;
console.log('balanceTs is visible:', asAny['balanceTs']);
// The JavaScript private genuinely is not there.
console.log('own property names:', Object.getOwnPropertyNames(account));
console.log('JSON sees only one of them:', JSON.stringify(account));

Output


      

Both are checked by the compiler. Only one survives.

Both are compile errors, for what it 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'.

readonly, protected, and static

The remaining modifiers

class Entity {
  // readonly: assignable in the constructor, never afterwards.
  readonly id: string;

  // protected: reachable from this class and its subclasses, not outside.
  protected createdAt: Date;

  // static: on the class, not on instances.
  static count = 0;

  // A static method, and a private static helper.
  static nextId(): string {
    return `e${++Entity.count}`;
  }

  constructor() {
    this.id = Entity.nextId();
    this.createdAt = new Date('2026-01-01T00:00:00Z');
  }
}

class Document2 extends Entity {
  constructor(public title: string) {
    super();
  }

  // A subclass can reach protected members.
  age(now: Date): number {
    return now.getTime() - this.createdAt.getTime();
  }
}

const doc = new Document2('Notes');
console.log(doc.id, doc.title);
console.log('static count:', Entity.count);
console.log('age in ms:', doc.age(new Date('2026-01-02T00:00:00Z')));

Output


      

protected is not public

TS2445
class Entity {
  protected createdAt = new Date();
}

const entity = new Entity();

console.log(entity.createdAt);

What the compiler says

error TS2445: Property 'createdAt' is protected and only accessible within class 'Entity' and its subclasses.

implements: promising to match a shape

An interface describes a shape. implements declares that a class provides that shape, and asks the compiler to check it. This is the mechanism behind “program to an interface” from the OOP course, and it is the thing TypeScript adds that JavaScript could not.

One interface, several implementations

interface Serialiser {
  readonly name: string;
  serialise(value: unknown): string;
}

class JsonSerialiser implements Serialiser {
  readonly name = 'json';

  serialise(value: unknown): string {
    return JSON.stringify(value);
  }
}

class QuerySerialiser implements Serialiser {
  readonly name = 'query';

  serialise(value: unknown): string {
    if (typeof value !== 'object' || value === null) return String(value);
    return Object.entries(value)
      .map(([k, v]) => `${k}=${String(v)}`)
      .join('&');
  }
}

// The calling code depends on the interface, not on either class.
function send(serialiser: Serialiser, payload: unknown): string {
  return `[${serialiser.name}] ${serialiser.serialise(payload)}`;
}

const payload = { id: 7, name: 'Ada' };

for (const s of [new JsonSerialiser(), new QuerySerialiser()]) {
  console.log(send(s, payload));
}

Output


      

A class missing something the interface requires

TS2420
interface Serialiser {
  readonly name: string;
  serialise(value: unknown): string;
}

// No `name` property. The compiler reports the class as a whole.
class Broken implements Serialiser {
  serialise(value: unknown): string {
    return String(value);
  }
}

console.log(new Broken().serialise('x'));

What the compiler says

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

A member that exists but has the wrong signature produces a different, more specific message — naming the property rather than the class.

A member whose signature does not match

TS2416
interface Serialiser {
  serialise(value: unknown): string;
}

class Broken implements Serialiser {
  // Right name, wrong return type.
  serialise(value: unknown): number {
    return String(value).length;
  }
}

console.log(new Broken().serialise('x'));

What the compiler says

error TS2416: Property 'serialise' in type 'Broken' is not assignable to the same property in base type 'Serialiser'.

Structural typing means implements is optional

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

// Says implements. Checked at the class.
class Polite implements Greeter {
  greet(name: string): string { return `Good day, ${name}`; }
}

// Says nothing. Still accepted, because the shape matches.
class Blunt {
  greet(name: string): string { return `Oi, ${name}`; }
}

function welcome(greeter: Greeter, name: string): string {
  return greeter.greet(name);
}

console.log(welcome(new Polite(), 'Ada'));
console.log(welcome(new Blunt(), 'Grace'));

// Even a plain object works, because none of this is nominal.
console.log(welcome({ greet: (n) => `Hello ${n}` }, 'Alan'));

Output


      

abstract classes

An abstract class is a partial implementation: shared behaviour plus a list of things subclasses must provide. Where an interface has no implementation at all, an abstract class can carry both.

Shared behaviour, required overrides

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

  // No body: every subclass must supply one.
  abstract area(): number;

  // A concrete method, shared by all subclasses, that uses the abstract one.
  describe(): string {
    return `${this.label} has area ${this.area().toFixed(2)}`;
  }
}

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

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

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

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

const shapes: Shape[] = [new Circle(1), new Rectangle(3, 4)];
for (const shape of shapes) console.log(shape.describe());

Output


      

An abstract class cannot be instantiated

TS2511
abstract class Shape {
  abstract area(): number;
}

const shape = new Shape();

console.log(shape.area());

What the compiler says

error TS2511: Cannot create an instance of an abstract class.

A subclass must implement every abstract member

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

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

console.log(new Square(2).area());

What the compiler says

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

override, and the flag that makes it mean something

noImplicitOverride is on for this course. It requires the override keyword whenever you replace an inherited member, which catches a specific and genuinely nasty bug: renaming a method in a base class and silently orphaning the subclass version.

Overriding without saying so

TS4114
class Base {
  greet(): string { return 'hello'; }
}

class Child extends Base {
  // This replaces Base.greet, and with noImplicitOverride on you have to say
  // that on purpose.
  greet(): string { return 'hi'; }
}

console.log(new Child().greet());

What the compiler says

error TS4114: This member must have an 'override' modifier because it overrides a member in the base class 'Base'.

And override on something that overrides nothing

TS4113
class Base {
  greet(): string { return 'hello'; }
}

class Child extends Base {
  // Claims to override, but the base has no `salute`. This is what catches a
  // rename in the base class: the subclass stops compiling instead of
  // quietly becoming dead code.
  override salute(): string { return 'hi'; }
}

console.log(new Child().salute());

What the compiler says

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

Generic classes, and this typing

A generic class, and returning this for chaining

class Collection<T> {
  #items: T[] = [];

  add(item: T): this {
    this.#items.push(item);
    return this;                 // `this`, not Collection<T>, so chaining works
  }                              // correctly in subclasses too

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

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

// A subclass adds a method, and chaining still returns the subclass type.
class NumberCollection extends Collection<number> {
  sum(): number {
    return this.toArray().reduce((a, b) => a + b, 0);
  }
}

const names = new Collection<string>().add('Ada').add('Grace');
console.log(names.size, names.toArray());

// Because add() returns `this`, sum() is still available after chaining.
const numbers = new NumberCollection().add(1).add(2).add(3);
console.log(numbers.sum(), numbers.size);

Output


      

Classes as types, and instanceof

A class gives you a type and a value

class User {
  constructor(
    public id: number,
    public name: string,
  ) {}
}

// The class name works as a type for instances.
function describe(user: User): string {
  return `#${user.id} ${user.name}`;
}

// And it is a real value, so instanceof works — unlike an interface.
function isUser(value: unknown): boolean {
  return value instanceof User;
}

const real = new User(1, 'Ada');
const lookalike = { id: 2, name: 'Grace' };

console.log(describe(real));

// Structural typing means the plain object is accepted as a User...
console.log(describe(lookalike));

// ...but instanceof asks a different question, about the prototype chain.
console.log('real:', isUser(real), '| lookalike:', isUser(lookalike));

Output


      

A private field makes a class nominal in practice

TS2741
class Token {
  // A #field cannot be replicated by an object literal, so this class no
  // longer accepts structural lookalikes.
  #brand = true;

  constructor(public value: string) {}
}

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

console.log(use({ value: 'looks-like-a-token' }));

What the compiler says

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

Putting it together

A small typed hierarchy, and its output

interface Repository<T> {
  save(item: T): void;
  findById(id: number): T | undefined;
  all(): readonly T[];
}

abstract class InMemoryRepository<T extends { id: number }> implements Repository<T> {
  #items = new Map<number, T>();

  save(item: T): void {
    this.#items.set(item.id, item);
  }

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

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

  abstract describe(item: T): string;

  report(): string {
    return this.all().map((item) => this.describe(item)).join('; ');
  }
}

interface Product {
  id: number;
  sku: string;
  price: number;
}

class ProductRepository extends InMemoryRepository<Product> {
  override describe(product: Product): string {
    return `${product.sku} at £${product.price}`;
  }

  cheaperThan(limit: number): readonly Product[] {
    return this.all().filter((p) => p.price < limit);
  }
}

const repo = new ProductRepository();
repo.save({ id: 1, sku: 'desk', price: 250 });
repo.save({ id: 2, sku: 'chair', price: 90 });
repo.save({ id: 3, sku: 'lamp', price: 40 });

console.log(repo.findById(2)?.sku);
console.log(repo.report());
console.log('under 100:', repo.cheaperThan(100).map((p) => p.sku));
What this compiles to
class InMemoryRepository {
    #items = new Map();
    save(item) {
        this.#items.set(item.id, item);
    }
    findById(id) {
        return this.#items.get(id);
    }
    all() {
        return [...this.#items.values()];
    }
    report() {
        return this.all().map((item) => this.describe(item)).join('; ');
    }
}
class ProductRepository extends InMemoryRepository {
    describe(product) {
        return `${product.sku} at £${product.price}`;
    }
    cheaperThan(limit) {
        return this.all().filter((p) => p.price < limit);
    }
}
const repo = new ProductRepository();
repo.save({ id: 1, sku: 'desk', price: 250 });
repo.save({ id: 2, sku: 'chair', price: 90 });
repo.save({ id: 3, sku: 'lamp', price: 40 });
console.log(repo.findById(2)?.sku);
console.log(repo.report());
console.log('under 100:', repo.cheaperThan(100).map((p) => p.sku));

Output


      

Your turn

  1. Write a BankAccount class with a balance that cannot be read or written from outside. Do it with #, then check JSON.stringify and Object.keys on an instance to confirm the field is genuinely absent.
  2. Rewrite the same class with private instead, then reach the field anyway by casting the instance to Record<string, unknown>. Decide which version you would ship.
  3. Write an interface with two methods and a class that implements it but gets one return type wrong. Read the error and note that it points at the class.
  4. Write an abstract class with one concrete method and one abstract method, then a subclass that forgets the abstract one. Read the error code.
  5. Add a method to a subclass that shadows a base method without override. Read the error. Then rename the base method and confirm the override keyword catches the orphan.
  6. Write a fluent class whose add returns this, subclass it, and confirm you can chain and then call the subclass method. Change the return type to the class name and watch it break.

Recap

  • Everything from the OOP course still applies. What TypeScript adds is that the contracts are now checked.
  • private is a compile-time annotation and is reachable at run time; # is a real JavaScript feature and is not. Use # when the privacy matters.
  • Parameter properties (constructor(public x: number)) emit code rather than being erased, which is why node file.ts rejects them.
  • implements adds nothing and checks everything. Because typing is structural, it is optional — but it puts the error on the class rather than at every call site.
  • Prefer an interface to an abstract class unless there is real shared implementation to inherit.
  • noImplicitOverride makes override mandatory, which turns a renamed base method from silent dead code into a compile error.
  • Return this rather than the class name from fluent methods, so chaining survives subclassing.

Saved in this browser only.

Newsletter

New writing, when it lands.

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