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 and 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, and 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 that points to the shared instance so inherits its behavior. 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 the 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 realize that the underlying behavior is the same, with constructor functions and prototype objects.

Accessibility, Properties, and 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 initialize 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 initialization.

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 initialize 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 deserialize 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 serialization and they won’t have any of the methods.

This is true even when we include a type assertion. Realize 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 that 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 behavior. 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 serialize out objects to JSON.

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

Serializes 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 serialize the private fields are not present. So, an instance of this Secrets class serializes 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.



Source link

Write A Comment