TypeScript 4.3 - I object your honour!

In celebration of TypeScript 4.3 I take a look TypeScript's Object Oriented features and look at the new features this release brings.

TypeScript provides good support for both object-oriented programming and function-oriented programming (I'm not saying functional programming as I don't want the purists to hunt me down).

In this post I'm going to review OO support within TypeScript and show the latest OO features provided in TypeScript 4.3.

I'll quickly cover:

  • Basic Class Mechanics & the Prototype-based System
  • Accessibility and Parameter Properties
  • Inheritance, Types and Type Assertions
  • Accessors and 4.3's support for differing types
  • ECMAScript Private and 4.3's extension to methods and accessors
  • 4.3's new override keyword

TypeScript OO Review

Classes, Objects & Prototypes, oh my!

Object-Oriented programming in JavaScript has always been a little bit strange and with TypeScript being a superset of JavaScript, some of that funk is still hanging in the air.

The prototype system for managing classes can feel strange when coming from other object-oriented languages like Java, C# or C++. In JavaScript, a class actually creates an object in memory, just like any other object, to contain common functionality that all instances of that class share (typically methods). This object is the prototype, accessed via the prototype property from the class and the __proto__ property from all instances.

Prototype System

When we instantiate an object of a class, we actually create a new blank object, but that blank object has a prototype which points to the shared instance so inherits its behaviour. The constructor function runs which will typically populate the blank object with properties.

In the olden days, this was more visible where we'd actually write classes as functions and manipulate the prototype for the function/class.

function Circle(radius) {
    this.radius = radius;
}

Circle.prototype.diameter = function () {
    return this.radius * 2;
}

Circle.prototype.area = function () {
    return this.radius * this.radius * Math.PI;
}

Of course, since ECMAScript 2015 (ES6) we have proper class syntax:

class Circle {
    constructor(radius) {
        this.radius = radius;
    }

    diameter() {
        return this.radius * 2;
    }

    area() {
        return this.radius * this.radius * Math.PI;
    }
}

and this syntax carries over to TypeScript:

class Circle {
    radius: number;

    constructor(radius: number) {
        this.radius = radius;
    }

    diameter(): number {
        return this.radius * 2;
    }

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

But realise that the underlying behaviour is the same, with constructor functions and prototype objects.

Accessibility, Properties & Parameter Properties

Notice that in our Circle class in TypeScript we had to define a radius property. This property can be configured to be private, protected or public (with public being the default). These accessibility modifiers also apply to methods.

          radius: number; // public
public    radius: number;
protected radius: number;
private   radius: number;

In the constructor, as is often the case, we initialise the radius property with a parameter passed into the constructor. This can be simplified by adding an accessibility modifier to the constructer parameter, making it a parameter property. This removes the need for the property declaration and initialisation.

class Circle {
    radius: number;

    constructor(radius: number) {
        this.radius = radius;
    }
}

// becomes

class Circle {
    constructor(public radius: number) {}
}

We can also qualify properties as readonly. This also works to create parameter properties.

class Circle {
    readonly radius: number;

    constructor(radius: number) {
        this.radius = radius;
    }
}

// or

class Circle {
    constructor(public readonly radius: number) {}
}

// or simply

class Circle {
    constructor(readonly radius: number) {}
}

Finally, we can also initialise properties outside of the constructor. Even using other properties and methods.

class Circle {
    readonly diameter = this.radius * 2;
    readonly area = this.calculateArea();

    constructor(readonly radius: number) {}

    private calculateArea(): number {
        return this.radius * this.radius * Math.PI;
    }
}

Inheritance

Prototype chains support OO inheritance which is easily implemented using extends.

class Shape {
    constructor(sides: number) {
    }

    toString() {
        return "Shape";
    }
}

class Circle extends Shape {
    readonly diameter = this.radius * 2;

    constructor(readonly radius: number) {
        super(1);
    }

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

Prototype System with Inheritance

What's in a Type

A key concept to remember with classes in TypeScript is that an object is an instance of a class because of its prototype.

However, when we deserialise from JSON we are recreating objects that have no link to any classes we've written. They will not appear as instances of the class that was used to originally create them before serialisation and they won't have any of the methods.

This is true even when we include a type assertion. Realise that type assertions are not casts; we are only making an assertion. We are informing the compiler about something that it is not capable of inferring for itself. This is usually at an I/O boundary or when calling into untyped JavaScript. However, it is possible for us to give incorrect information - such as assert that a basic object is an instance of a class.

class Circle {
    readonly diameter = this.radius * 2;

    constructor(readonly radius: number) {}

    prettyPrint() {
        console.log(`Circle with radius ${this.radius}`);
    }
}

const circle1 = new Circle(12);
const json = JSON.stringify(circle1); // {"radius":12,"diameter":24}
const circle2 = JSON.parse(json) as Circle;

console.log(circle2.radius);   // 12
console.log(circle2.diameter); // 24

console.log(circle2 instanceof Circle); // ** false
console.log(circle2.prettyPrint());     // ** TypeError: circle2.prettyPrint is not a function

This is why it is often better when transferring data to use interfaces with no methods. You may find with TypeScript that you tend towards a more function-oriented approach for this reason, using basic objects adhering to an interface and free functions that take these objects as inputs.

The Latest Features

Accessors with Conversion

Accessors are useful for providing an interface which is consumed like a property but with more complicated logic under the hood. Using get and/or set, we can bind a property to a getter and/or setter functions. This is useful for example when creating computed properties.

class Circle {
    constructor(public radius: number) {}

    get diameter(): number {
        return this.radius * 2;
    }

    set diameter(value: number) {
        this.radius = value / 2;
    }
}

const circle = new Circle(12);
console.log(circle.diameter); // 24
circle.diameter = 100;
console.log(circle.radius);   // 50

Here, diameter looks and feels like a property to the consumer, but in reality it is reading and writing radius.

TypeScript 4.3 adds support for having a setter type that is more than the getter type. This can be useful for supporting writing multiple data types which will ultimately be converted to a canonical type. For example, below, we can write diameter as a number or string, but we always read it back as a number.

class Circle {
    constructor(public radius: number) {}

    get diameter(): number {
        return this.radius * 2;
    }

    set diameter(value: number | string) {
        value = Number(value);
        this.radius = value / 2;
    }
}

const circle = new Circle(12);
console.log(circle.diameter); // 24
circle.diameter = "100";      // Writing as string
console.log(circle.radius);   // 50

While you will not want to do this everywhere, it is useful for wrapping existing APIs with similar behaviour. In 4.3 we also get support for specifying separate getter and setter types in an interface.

interface Circle {
    get diameter(): number;
    set diameter(value: number | string);
}

The only restriction is that the getter type must be assignable to the setter type.

True Private Members

It is worth noting, that like many things in TypeScript, private and protected modifiers only apply at compile time. They are there to help and protect the developer. At runtime, when everything has been converted to JavaScript, all properties will exist in the object and are accessible. This includes when we serialise out objects to JSON.

class Secrets {
    private key = "123";
    private id = "abc";
}

Serialises out as:

{
    "key": "123",
    "id": "abc"
}

To provide true privacy, TypeScript added support for ECMAScript Private fields in version 3.8. This is only a stage 3 proposal (at the time of writing) for JavaScript but is part of the TypeScript language since 3.8. These fields are prefixed with a #.

class Secrets {
    #key = "123";
    #id = "abc";

    prettyPrint() {
        console.log(`${this.#key} - ${this.#id}`);
    }
}

This stores the value for the fields/properties external to the object so when we serialise the private fields are not present. So, an instance of this Secrets class serialises out as:

{}

The compilation of this to older JavaScript is interesting - it creates a module level WeakMap for each field and an object has to look up its field value in this structure, using itself as the key.

var _key = new WeakMap();
var _id = new WeakMap();

class Secrets {
    constructor() {
        _key.set(this, "123");
        _id.set(this, "abc");
    }

    prettyPrint() {
        console.log(`${_key.get(this)} - ${_id.get(this)}`);
    }
}

TypeScript 4.3 extends ECMAScript private support to include methods and accessors.

class Secrets {
    #key = "123";
    #id = "abc";

    get #params(): string {
        return `key=${this.#key}&id=${this.#id}`;
    }

    #reset() {
        // ...
    }
}

Clarity in the Override

Prior to TypeScript 4.3, when overriding methods in a subclass, you simply used the same name. This could lead to subtle errors when the base class changed but the subclasses weren't updated. For example, when a base class method was removed.

TypeScript 4.3 introduces the override keyword to explicitly mark methods as overridden. If there is not a suitable base entry to override then an error is flagged. This also better communicates intent to the reader.

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

class Circle extends Shape {
    // ...
    override area(): number {
        return this.radius * this.radius * Math.PI;
    }
}

To avoid breaking-changes a new compiler switch, --noImplicitOverride, flags up errors when a method overrides a base method without this keyword.

Conclusion

I hope this was a useful summary of Object-Oriented support in TypeScript. As you can see, TypeScript 4.3 adds some useful features for this paradigm.

If you haven't already done so, check out my posts showing some of the useful features in TypeScript 4.1 and TypeScript 4.2.

Also, be sure to check out our TypeScript course. We're also happy to deliver Angular & React training using TypeScript. We deliver virtually to companies all over the world and are happy to customise our courses to tailor to your team's level and specific needs. Come and check us out to see if we can help you and your team.

Article By
blog author

Eamonn Boyle

Instructor, developer