HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

21 Class definitions in TypeScript

In this chapter, we examine how class definitions work in TypeScript:

21.1 Cheat sheet: classes in plain JavaScript

This section is a cheat sheet for class definitions in plain JavaScript.

21.1.1 Basic members of classes

class OtherClass {}

class MyClass1 extends OtherClass {
  publicInstanceField = 1;
  constructor() {
    super();
  }
  publicPrototypeMethod() {
    return 2;
  }
}

const inst1 = new MyClass1();
assert.equal(inst1.publicInstanceField, 1);
assert.equal(inst1.publicPrototypeMethod(), 2);

Icon “reading”The next sections are about modifiers

At the end, there is a table that shows how modifiers can be combined.

21.1.2 Modifier: static

class MyClass2 {
  static staticPublicField = 1;
  static staticPublicMethod() {
    return 2;
  }
}

assert.equal(MyClass2.staticPublicField, 1);
assert.equal(MyClass2.staticPublicMethod(), 2);

21.1.3 Modifier-like name prefix: # (private)

class MyClass3 {
  #privateField = 1;
  #privateMethod() {
    return 2;
  }
  static accessPrivateMembers() {
    // Private members can only be accessed from inside class definitions
    const inst3 = new MyClass3();
    assert.equal(inst3.#privateField, 1);
    assert.equal(inst3.#privateMethod(), 2);
  }
}

21.1.4 Modifiers for accessors: get (getter) and set (setter)

Roughly, accessors are prototype methods that are inherited by instances and invoked by accessing properties. There are two kinds of accessors: getters and setters.

class MyClass4 {
  #name = 'Rumpelstiltskin';
  
  /** Prototype getter */
  get name() {
    return this.#name;
  }

  /** Prototype setter */
  set name(value) {
    this.#name = value;
  }
}
const inst5 = new MyClass4();
assert.equal(inst5.name, 'Rumpelstiltskin'); // getter
inst5.name = 'Queen'; // setter
assert.equal(inst5.name, 'Queen'); // getter

21.1.5 Modifier for methods: * (generator)

class MyClass5 {
  * publicPrototypeGeneratorMethod() {
    yield 'hello';
    yield 'world';
  }
}

const inst6 = new MyClass5();
assert.deepEqual(
  Array.from(inst6.publicPrototypeGeneratorMethod()),
  ['hello', 'world']
);

21.1.6 Modifier for methods: async

class MyClass6 {
  async publicPrototypeAsyncMethod() {
    const result = await Promise.resolve('abc');
    return result + result;
  }
}

const inst7 = new MyClass6();
assert.equal(
  await inst7.publicPrototypeAsyncMethod(),
  'abcabc'
);

21.1.7 Computed class member names

const publicInstanceFieldKey = Symbol('publicInstanceFieldKey');
const publicPrototypeMethodKey = Symbol('publicPrototypeMethodKey');

class MyClass7 {
  [publicInstanceFieldKey] = 1;
  [publicPrototypeMethodKey]() {
    return 2;
  }
}

const inst8 = new MyClass7();
assert.equal(inst8[publicInstanceFieldKey], 1);
assert.equal(inst8[publicPrototypeMethodKey](), 2);

Comments:

21.1.8 Combinations of modifiers

Fields:

LevelPrivateCode
(instance)field
(instance)##field
staticstatic field
static#static #field

Methods (columns: Level, Accessor, Async, Generator, Private, Code – without body):

LevelAccAsyncGenPrivCode
(prototype)m()
(prototype)getget p()
(prototype)setset p(x)
(prototype)asyncasync m()
(prototype)** m()
(prototype)async*async * m()
(prototype-ish)##m()
(prototype-ish)get#get #p()
(prototype-ish)set#set #p(x)
(prototype-ish)async#async #m()
(prototype-ish)*#* #m()
(prototype-ish)async*#async * #m()
staticstatic m()
staticgetstatic get p()
staticsetstatic set p(x)
staticasyncstatic async m()
static*static * m()
staticasync*static async * m()
static#static #m()
staticget#static get #p()
staticset#static set #p(x)
staticasync#static async #m()
static*#static * #m()
staticasync*#static async * #m()

21.1.9 Under the hood

It’s important to keep in mind that with classes, there are two chains of prototype objects:

Consider the following plain JavaScript example:

class ClassA {
  static staticMthdA() {}
  constructor(instPropA) {
    this.instPropA = instPropA;
  }
  prototypeMthdA() {}
}
class ClassB extends ClassA {
  static staticMthdB() {}
  constructor(instPropA, instPropB) {
    super(instPropA);
    this.instPropB = instPropB;
  }
  prototypeMthdB() {}
}
const instB = new ClassB(0, 1);

Figure 21.1 shows what the prototype chains look like that are created by ClassA and ClassB.

Figure 21.1: The classes ClassA and ClassB create two prototype chains: One for classes (left-hand side) and one for instances (right-hand side).

21.1.10 More information on class definitions in plain JavaScript

21.2 Non-public data slots in TypeScript

By default, all data slots in TypeScript are public properties. There are two ways of keeping data private:

We’ll look at both next.

Note that TypeScript does not currently support private methods.

21.2.1 Private properties

Private properties are a TypeScript-only (static) feature. Any property can be made private by prefixing it with the keyword private (line A):

class PersonPrivateProperty {
  private name: string; // (A)
  constructor(name: string) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}

We now get compile-time errors if we access that property in the wrong scope (line A):

const john = new PersonPrivateProperty('John');

assert.equal(
  john.sayHello(), 'Hello John!'
);

// @ts-expect-error: Property 'name' is private and only accessible
// within class 'PersonPrivateProperty'.
john.name; // (A)

However, private doesn’t change anything at runtime. There, property .name is indistinguishable from a public property:

assert.deepEqual(
  Object.keys(john),
  ['name']
);

We can also see that private properties aren’t protected at runtime when we look at the JavaScript code that the class is compiled to:

class PersonPrivateProperty {
  constructor(name) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}

21.2.2 Private fields

Private fields are a new JavaScript feature that TypeScript has supported since version 3.8:

class PersonPrivateField {
  #name: string;
  constructor(name: string) {
    this.#name = name;
  }
  sayHello() {
    return `Hello ${this.#name}!`;
  }
}

This version of Person is mostly used the same way as the private property version:

const john = new PersonPrivateField('John');

assert.equal(
  john.sayHello(), 'Hello John!'
);

However, this time, the data is completely encapsulated. Using the private field syntax outside classes is even a JavaScript syntax error. That’s why we have to use eval() in line A so that we can execute this code:

assert.throws(
  () => eval('john.#name'), // (A)
  {
    name: 'SyntaxError',
    message: "Private field '#name' must be declared in "
      + "an enclosing class",
  }
);

assert.deepEqual(
  Object.keys(john),
  []
);

Compiled to JavaScript, PersonPrivateField looks more or less the same:

class PersonPrivateField {
  #name;
  constructor(name) {
    this.#name = name;
  }
  sayHello() {
    return `Hello ${this.#name}!`;
  }
}

21.2.3 Private properties vs. private fields

21.2.4 Protected properties

Private fields and private properties can’t be accessed in subclasses (line B):

class PrivatePerson {
  private name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}
class PrivateEmployee extends PrivatePerson {
  private company: string;
  constructor(name: string, company: string) {
    super(name);
    this.company = company;
  }
  override sayHello() { // (A)
    // @ts-expect-error: Property 'name' is private and only
    // accessible within class 'PrivatePerson'.
    return `Hello ${this.name} from ${this.company}!`; // (B)
  }  
}

The keyword override is explained later – it’s for methods that override super-methods.

We can fix the previous example by switching from private to protected in line A (we also switch in line B, for consistency’s sake):

class ProtectedPerson {
  protected name: string; // (A)
  constructor(name: string) {
    this.name = name;
  }
  sayHello() {
    return `Hello ${this.name}!`;
  }
}
class ProtectedEmployee extends ProtectedPerson {
  protected company: string; // (B)
  constructor(name: string, company: string) {
    super(name);
    this.company = company;
  }
  override sayHello() {
    return `Hello ${this.name} from ${this.company}!`; // OK
  }  
}

21.3 Private constructors

At the moment, JavaScript does not support hash-private constructors. However, TypeScript supports private for them. That is useful when we have static factory methods and want clients to always use those methods, never the constructor directly. Static methods can access private class members, which is why the factory methods can still use the constructor.

In the following code, there is one static factory method DataContainer.create(). It sets up instances via asynchronously loaded data. Keeping the asynchronous code in the factory method enables the actual class to be completely synchronous:

class DataContainer {
  #data: string;
  static async create() {
    const data = await Promise.resolve('downloaded'); // (A)
    return new this(data);
  }
  private constructor(data: string) {
    this.#data = data;
  }
  getData() {
    return 'DATA: '+this.#data;
  }
}
const dataContainer = await DataContainer.create();
assert.equal(
  dataContainer.getData(),
  'DATA: downloaded'
);

In real-world code, we would use fetch() or a similar Promise-based API to load data asynchronously in line A.

The private constructor prevents DataContainer from being subclassed. If we want to allow subclasses, we have to make it protected.

21.4 Initializing instance properties

21.4.1 Strict property initialization

If the compiler setting --strictPropertyInitialization is switched on (which is the case if we use --strict), then TypeScript checks if all declared instance properties are correctly initialized:

However, sometimes we initialize properties in a manner that TypeScript doesn’t recognize. Then we can use exclamation marks (definite assignment assertions) to switch off TypeScript’s warnings (line A and line B):

class Point {
  x!: number; // (A)
  y!: number; // (B)
  constructor() {
    this.initProperties();
  }
  initProperties() {
    this.x = 0;
    this.y = 0;
  }
}
21.4.1.1 Example: setting up instance properties via objects

In the following example, we also need definite assignment assertions. Here, we set up instance properties via the constructor parameter props:

class CompilerError implements CompilerErrorProps { // (A)
  line!: number;
  description!: string;
  constructor(props: CompilerErrorProps) {
    Object.assign(this, props); // (B)
  }
}

// Helper interface for the parameter properties
interface CompilerErrorProps {
  line: number,
  description: string,
}

// Using the class:
const err = new CompilerError({
  line: 123,
  description: 'Unexpected token',
});

Notes:

21.5 Convenience features we should avoid

21.5.1 Inferred member types

tsc can infer the type of the member .str because we assign to it in line A. However, that is not compatible with the compiler option isolatedDeclarations (which enables external tools to generate declarations without doing inference):

class C {
  str;
  constructor(str: string) {
    this.str = str; // (A)
  }
}

21.5.2 Making constructor parameters public, private or protected

JavaScript currently has no equivalent to the TypeScript feature described in this subsection – which is why it is illegal if the compiler option erasableSyntaxOnly is active.

If we use the modifier public for a constructor parameter prop, then TypeScript does two things for us:

This is an example:

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

If we use private or protected instead of public, then the corresponding instance properties are private or protected.

The TypeScript class Point is compiled to the following JavaScript code:

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

21.6 Abstract classes

Two constructs can be abstract in TypeScript:

The following code demonstrates abstract classes and methods.

On one hand, there is the abstract superclass Printable and its helper class StringBuilder:

class StringBuilder {
  string = '';
  add(str: string) {
    this.string += str;
  }
}
abstract class Printable {
  toString() {
    const out = new StringBuilder();
    this.print(out);
    return out.string;
  }
  abstract print(out: StringBuilder): void;
}

On the other hand, there are the concrete subclasses Entries and Entry:

class Entries extends Printable {
  entries: Entry[];
  constructor(entries: Entry[]) {
    super();
    this.entries = entries;
  }
  print(out: StringBuilder): void {
    for (const entry of this.entries) {
      entry.print(out);
    }
  }
}
class Entry extends Printable {
  key: string;
  value: string;
  constructor(key: string, value: string) {
    super();
    this.key = key;
    this.value = value;
  }
  print(out: StringBuilder): void {
    out.add(this.key);
    out.add(': ');
    out.add(this.value);
    out.add('\n');
  }
}

And finally, this is us using Entries and Entry:

const entries = new Entries([
  new Entry('accept-ranges', 'bytes'),
  new Entry('content-length', '6518'),
]);
assert.equal(
  entries.toString(),
  'accept-ranges: bytes\ncontent-length: 6518\n'
);

Notes about abstract classes:

21.7 Keyword override for methods

The keyword override is for methods that override methods in superclasses – e.g.:

class A {
  m(): void {}
}
class B extends A {
  // `override` is required
  override m(): void {} // (A)
}

If the compiler option noImplicitOverride is active then TypeScript complains if there is no override in line A.

We can also use override when we implement an abstract method. That’s not required but I find it useful information:

abstract class A {
  abstract m(): void;
}
class B extends A {
  // `override` is optional
  override m(): void {}
}

21.8 Classes vs. object types

In JavaScript, we don’t have to use classes, we can also use objects directly. TypeScript supports both approaches.

21.8.1 Class Counter

This is a class that implements a counter:

class Counter {
  count = 0;
  inc(): void {
    this.count++;
  }
}

// Trying out the functionality
const counter = new Counter();
counter.inc();
assert.equal(
  counter.count, 1
);

21.8.2 Object type Counter

In TypeScript, a class defines both a type and a factory for instances. In the following code, both are separate: We have the object type Counter and the factory createCounter().

type Counter = {
  count: number,
};
function createCounter(): Counter {
  return {
    count: 0,
  };
}
function inc(counter: Counter): void {
  counter.count++;
}

// Trying out the functionality
const counter = createCounter();
inc(counter);
assert.equal(
  counter.count, 1
);

21.8.3 Which one to choose: class or object type?

Benefits of classes:

Benefits of object types:

Serializing and deserializing (to/from JSON etc.) is an interesting use case:

Apart from these criteria, which one to choose depends on whether you prefer code that is more object-oriented or code that is more functional.

We have not covered inheritance – where you also have a choice between an object-oriented coding style (classes) and a functional coding style (discriminated unions). For more information, see “Class hierarchies vs. discriminated unions” (§19.3).