9 / 12
Guide Book ยท Chapter 9

Classes

Fields, the four visibility levels, `implements` versus `extends`, abstract classes, and why `private` is not private at run time.

Classes are one of the few TypeScript constructs that survive to run time โ€” they are JavaScript classes with types layered on.

Fields and constructors

typescript
class Account {
  readonly id: string
  balance = 0                     // inferred: number
  owner?: string                  // optional

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

  deposit(amount: number): this {
    this.balance += amount
    return this
  }
}

const a = new Account("acc-1").deposit(50).deposit(25)
console.log(a.id, a.balance, a.owner ?? "(unowned)")

Parameter properties collapse the declare-and-assign pair into the signature:

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

  plus(other: Point): Point {
    return new Point(this.x + other.x, this.y + other.y)
  }

  toString(): string {
    return `(${this.x}, ${this.y})`
  }
}

console.log(String(new Point(1, 2).plus(new Point(3, 4))))

Visibility

ModifierChecked byVisible at run time
public (default)โ€”yes
protectedcompileryes
privatecompileryes
#fieldthe JavaScript engineno
typescript
class Secret {
  private compilerOnly = "visible at run time"
  #truly = "hidden"

  reveal(): string {
    return `${this.compilerOnly} / ${this.#truly}`
  }
}

const s = new Secret()
console.log(s.reveal())
console.log(Object.keys(s))                          // ["compilerOnly"] โ€” # is absent
console.log((s as unknown as Record<string, string>).compilerOnly)
typescript
class Secret {
  private compilerOnly = "x"
}

console.log(new Secret().compilerOnly) // error! Property 'compilerOnly' is private and only accessible within class 'Secret'.

Getters and setters

typescript
class Temperature {
  #celsius = 0

  get celsius(): number {
    return this.#celsius
  }

  set celsius(v: number) {
    if (Number.isNaN(v)) throw new RangeError("Not a number")
    this.#celsius = v
  }

  get fahrenheit(): number {
    return this.#celsius * 1.8 + 32
  }
}

const t = new Temperature()
t.celsius = 100
console.log(t.celsius, t.fahrenheit)

try {
  t.celsius = NaN
} catch (e) {
  console.log((e as Error).message)
}

A getter with no setter infers a readonly-like property from the outside โ€” assignment is a compile error.

implements versus extends

implements is a check, not inheritance. It brings in nothing.

typescript
interface Serialisable {
  serialise(): string
}

interface Comparable<T> {
  compareTo(other: T): number
}

class Version implements Serialisable, Comparable<Version> {
  constructor(
    readonly major: number,
    readonly minor: number,
  ) {}

  serialise(): string {
    return `${this.major}.${this.minor}`
  }

  compareTo(other: Version): number {
    return this.major - other.major || this.minor - other.minor
  }
}

const versions = [new Version(2, 1), new Version(1, 9), new Version(2, 0)]
versions.sort((a, b) => a.compareTo(b))
console.log(versions.map((v) => v.serialise()).join(" < "))

โš ๏ธ implements does not add inferred parameter types. class C implements Handler { handle(e) {} } leaves e implicitly any โ€” the interface is checked against the class, not used to type it. Annotate the parameters.

Abstract classes

typescript
abstract class Shape {
  abstract area(): number
  abstract readonly name: string

  describe(): string {
    return `${this.name} with area ${this.area().toFixed(2)}`
  }
}

class Circle extends Shape {
  readonly name = "circle"
  constructor(private r: number) {
    super()
  }
  area(): number {
    return Math.PI * this.r ** 2
  }
}

class Square extends Shape {
  readonly name = "square"
  constructor(private side: number) {
    super()
  }
  area(): number {
    return this.side ** 2
  }
  override describe(): string {
    return `A ${super.describe()}`
  }
}

for (const s of [new Circle(1), new Square(3)]) {
  console.log(s.describe())
}
typescript
abstract class Shape {
  abstract area(): number
}

new Shape() // error! Cannot create an instance of an abstract class.

๐Ÿ’ก Tip: Turn on noImplicitOverride. It makes the override keyword mandatory, so renaming a base method turns every stale subclass method into a compile error instead of a silently-dead one.

Static members and this types

typescript
class Registry {
  static #instances = 0
  static readonly version = "1.0"

  static create(): Registry {
    Registry.#instances++
    return new Registry()
  }

  static get count(): number {
    return Registry.#instances
  }
}

Registry.create()
Registry.create()
console.log(Registry.count, Registry.version)

Returning this โ€” rather than the class name โ€” keeps method chains working in subclasses:

typescript
class Builder {
  protected parts: string[] = []

  add(part: string): this {
    this.parts.push(part)
    return this
  }

  build(): string {
    return this.parts.join(" ")
  }
}

class HtmlBuilder extends Builder {
  tag(name: string): this {
    return this.add(`<${name}>`)
  }
}

console.log(new HtmlBuilder().tag("p").add("hello").tag("/p").build())

Had add() been typed : Builder, the chain above would stop compiling at .tag() โ€” the return type would have forgotten the subclass.

Classes are structural too

A class type is still just a shape. Anything with the right members is assignable, whether or not it was built with new.

typescript
class Logger {
  log(message: string): void {
    console.log(`[log] ${message}`)
  }
}

function run(l: Logger): void {
  l.log("running")
}

run(new Logger())
run({ log: (m: string) => console.log(`[plain] ${m}`) })   // no `new` in sight

The exception is a class with private or # members: those are compared nominally, so only the declaring class can satisfy them.

Reference: Classes in the TypeScript Handbook.

main.ts
Loading editorโ€ฆ
No output yet
Press Run or โŒ˜โ†ต to compile and run your code.

Search

Jump to a lesson or a past run