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