Classes and interfaces

Type a class properly — and know when a plain function would have done.

11of 14
≈ 11Min read
8Parts

Fields are declared

unlike JavaScript, the shape is written down
class Order {
  id: string;
  items: Door[] = [];
  #total = 0;                    // private at runtime, not only to the compiler

  constructor(id: string) {
    this.id = id;
  }
}
strictPropertyInitialization

A field with no initialiser and no assignment in the constructor is an error. Give it a default, assign it, mark it optional, or — if something else really does set it — use ! and mean it.

Parameter properties

a shorthand worth knowing, because you will read it
class Order {
  constructor(
    public readonly id: string,
    private items: Door[] = [],
  ) {}
}

// the same as declaring both fields and assigning them in the body

public, private, protected — and #

# is real privacy, private is a convention the compiler enforces. Use # unless you need a subclass to see the field.

Enforced byAt runtime
publicNothing — it is the defaultVisible
privateThe compiler onlyVisible: obj["secret"] works
protectedThe compiler onlyVisible
#nameThe languageGenuinely inaccessible

implements

a promise checked at the class
interface Repository<T> {
  byId(id: string): T | undefined;
  add(item: T): void;
}

class DoorStore implements Repository<Door> {
  #items: Door[] = [];

  byId(id: string): Door | undefined { return this.#items.find((d) => d.id === id); }
  add(item: Door): void { this.#items.push(item); }
}
implements checks, it does not inherit

Nothing is added to the class; the compiler only verifies the shape. And it does not infer parameter types for you — annotate them anyway.

abstract

a base that cannot be instantiated
abstract class Product {
  constructor(public readonly id: string) {}

  abstract price(): number;                      // every subclass must write it

  describe(): string { return `${this.id}: £${this.price()}`; }
}

class Door extends Product {
  constructor(id: string, private base: number) { super(id); }
  price(): number { return this.base; }
}

Static, and the class as a value

two types for every class
class Door {
  static from(json: unknown): Door { … }
  static readonly kind = "door";
}

type DoorInstance = Door;                 // the instances
type DoorClass = typeof Door;             // the class itself, with its statics

function make(ctor: new (id: string) => Door) { return new ctor("a"); }

Getters, setters and this

and the return type that makes chaining work
class Store {
  #items: Door[] = [];

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

  add(item: Door): this {           // "this", not "Store" — subclasses chain too
    this.#items.push(item);
    return this;
  }
}

When not to use a class

Classes earn their place where there is genuine state and behaviour together, and a lifetime: a store, a connection, a parser. For the shape of a door, an interface is the whole answer.

  • A bag of data with no behaviour: an interface and a plain object are lighter, serialise cleanly and survive a round trip through JSON.
  • A single function pretending to be an object with one method.
  • Anything you will store in React state or send over the wire — a class instance loses its methods the moment it is serialised.

Practice