Class definitions in TypeScript

[2020-03-03] dev, javascript, typescript
(Ad, please don’t block)

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

  • First, we take a quick look at the features of class definitions in plain JavaScript.
  • Then we explore what additions TypeScript brings to the table.

Cheat sheet: classes in plain JavaScript  

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

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);

Modifier: static  

class MyClass2 {

  static staticPublicField = 1;

  static staticPublicMethod() {
    return 2;
  }
}

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

Modifier: # (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);
  }
}
MyClass3.accessPrivateMembers();

Warning:

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

There are two kinds of accessors: getters and setters.

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

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

Modifier for methods: * (generator)  

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

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

Modifier for methods: async  

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

const inst7 = new MyClass7();
inst7.publicPrototypeAsyncMethod()
  .then(result => assert.equal(result, 'abc'));

Computed class member names  

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

class MyClass8 {

  [publicInstanceFieldKey] = 1;

  [publicPrototypeMethodKey]() {
    return 2;
  }
}

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

Comments:

  • The main use case for this feature is symbols such as Symbol.iterator. But any expression can be used inside the square brackets.
  • We can compute the names of fields, methods, and accessors.
  • We cannot compute the names of private members (which are always fixed).

Combinations of modifiers  

Fields:

level visibility
(instance)
(instance) #
static
static #

Methods:

level accessor async generator visibility
(prototype)
(prototype) get
(prototype) set
(prototype) async
(prototype) *
(prototype) async *
(prototype-associated) #
(prototype-associated) get #
(prototype-associated) set #
(prototype-associated) async #
(prototype-associated) * #
(prototype-associated) async * #
static
static get
static set
static async
static *
static async *
static #
static get #
static set #
static async #
static * #
static async * #

Limitations of methods:

  • Accessors can’t be async or generators.

Under the hood  

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

  • The instance chain which starts with an instance.
  • The static chain which starts with the class of that instance.

Consider the following JavaScript (not TypeScript!) 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);

The two prototype chains look as follows:

More information  

Non-public data slots in TypeScript  

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

  • Private properties
  • Private fields

We’ll look at both next.

Note that TypeScript does not currently support private methods.

Private properties  

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-ignore: Property 'name' is private and only accessible
// within class 'PersonPrivateProperty'. (2341)
john.name; // (A)

However, private doesn’t change anything at runtime. There, the 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}!`;
  }
}

Private fields  

Since version 3.8, TypeScript also supports private fields:

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

That code is mostly used the same way as the other version:

const john = new PersonPrivateField('John');

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

However, this time, the data is completely safe. Using the private field syntax outside classes is even a JavaScript syntax error (which is why we have to use eval() in line A, so that the code runs):

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

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

The compilation result is much more complicated now:

var __classPrivateFieldSet = (this && this.__classPrivateFieldSet)
  || function (receiver, privateMap, value) {
    if (!privateMap.has(receiver)) {
      throw new TypeError(
        'attempted to set private field on non-instance');
    }
    privateMap.set(receiver, value);
    return value;
  };

// Omitted: __classPrivateFieldGet

var _name = new WeakMap();
class Person {
  constructor(name) {
    // Add an entry for this instance to _name
    _name.set(this, void 0);

    // Now we can use the helper function:
    __classPrivateFieldSet(this, _name, name);
  }
  // ···
}

This code uses a common technique for keeping instance data private:

  • Each WeakMap implements one private field.
  • It associates instances with private data.

For more information on this technique, see “JavaScript for impatient programmers”.

Private properties vs. private fields  

  • Downsides of private properties:
    • We can’t reuse the names of private properties in subclasses (because the properties aren’t private at runtime).
    • No protection at runtime.
  • Upside of private properties:
    • Clients can circumvent the protection and access private properties. This can be useful if someone needs to work around a bug. In other words: Being completely protected has pros and cons.

Protected properties  

Private properties can’t be accessed in subclasses (line A):

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;
  }
  sayHello() {
    // @ts-ignore: Property 'name' is private and only
    // accessible within class 'PrivatePerson'. (2341)
    return `Hello ${this.name} from ${this.company}!`; // (A)
  }  
}

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;
  }
  sayHello() {
    return `Hello ${this.name} from ${this.company}!`; // OK
  }  
}

Private constructors  

We can also make constructors private. 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 instance 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;
  }
}
DataContainer.create()
  .then(dc => assert.equal(
    dc.getData(), 'DATA: downloaded'));

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

Right now, the private constructor prevents DataContainer from being subclassed. If we want to allow subclasses, we can use protected.

Initializing instance properties  

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:

  • Either via assignments in the constructor:

    class Point {
      x: number;
      y: number;
      constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
      }
    }
    
  • Or via initializers for the property declarations:

    class Point {
      x = 0;
      y = 0;
    
      // No constructor needed
    }
    

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;
  }
}

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:

  • In line B, we initialize all properties: We use Object.assign() to copy the properties of parameter props into this.
  • In line A, the implements ensures that the class declares all properties that are part of interface CompilerErrorProps.

Making constructor parameters public, private, or protected  

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

  • It declares a public instance property with the same name.
  • It assigns the parameter to that instance property.

Therefore, the following two classes are equivalent:

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

class Point2 {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

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

Abstract classes  

Two constructs can be abstract in TypeScript:

  • An abstract class can’t be instantiated. Only its subclasses can – if they are not abstract, themselves.
  • An abstract method has no implementation, only a type signature. Each concrete subclass must have a concrete method with the same name and type signature as that abstract method.
    • If a class has any abstract methods, it must be abstract, too.

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:

  • They can be seen as interfaces with bundled implementations.
  • However, a class can only extend one abstract superclass but implement multiple interfaces.
  • “Abstractness” only exists at compile time. At runtime, abstract classes are normal classes and abstract methods don’t exist (due to them only providing compile-time information).
  • Abstract classes can be seen as templates where each abstract method is a blank that has to be filled in (implemented) by subclasses.