Learn TypeScript w/ Mike North

Classes

October 23, 2023

Table of Contents

TypeScript classes add some powerful and important features on top of traditional JavaScript classes. In this unit, we will take a closer look at class fields, access modifier keywords and more!

Fields and methods

Let’s go back to our car example. In the JS world, we could have something like:

js
////////////////////////////////
// JavaScript, not TypeScript //
////////////////////////////////
class Car {
constructor(make, model, year) {
this.make = make
this.model = model
(property) Car.model: any
this.year = year
}
}
 
let sedan = new Car("Honda", "Accord", 2017)
sedan.activateTurnSignal("left") // not safe!
new Car(2017, "Honda", "Accord") // not safe!
Try

If we stop and think for a moment, this is allowed in the JS world because every value, including the class fields and instances of the class itself, is effectively of type any.

In the TypeScript world, we want some assurance that we will be stopped at compile time from invoking the non-existent activateTurnSignal method on our car. In order to get this we have to provide a little more information up front:

ts
class Car {
make: string
model: string
year: number
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
(property) Car.model: string
this.year = year
}
}
 
let sedan = new Car("Honda", "Accord", 2017)
sedan.activateTurnSignal("left") // not safe!
Property 'activateTurnSignal' does not exist on type 'Car'.2339Property 'activateTurnSignal' does not exist on type 'Car'.
new Car(2017, "Honda", "Accord") // not safe!
Argument of type 'number' is not assignable to parameter of type 'string'.2345Argument of type 'number' is not assignable to parameter of type 'string'.
Try

Two things to notice in the code snippet above:

  • We are stating the types of each class field
  • We are stating the types of each constructor argument

This syntax is getting a bit verbose now — for example, the words “make”, “model” and “year” are written in four places each. As we will see below, TypeScript has a more concise way to write code like this.

Expressing types for class methods works using largely the same pattern used for function arguments and return types

ts
class Car {
make: string
model: string
year: number
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
honk(duration: number): string {
return `h${'o'.repeat(duration)}nk`;
}
}
 
const c = new Car("Honda", "Accord", 2017);
c.honk(5); // "hooooonk"
Try

static fields, methods and blocks

Sometimes it’s desirable to have fields and methods on the class, as opposed to the instance of that class. Recent additions to JavaScript and TypeScript make this possible!

The way to denote that a field or method should be treated this way is via the static keyword.

Here’s an example of a case where we want to have a counter that increments each time there’s a new instance.

ts
class Car {
// Static stuff
static nextSerialNumber = 100
static generateSerialNumber() { return this.nextSerialNumber++ }
 
// Instance stuff
make: string
model: string
year: number
serialNumber = Car.generateSerialNumber()
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
getLabel() {
return `${this.make} ${this.model} ${this.year} - #${this.serialNumber}`
}
}
console.log( new Car("Honda", "Accord", 2017))
// > "Honda Accord 2017 - #100
console.log( new Car("Toyota", "Camry", 2022))
// > "Toyota Camry 2022 - #101
Try

Unless you state otherwise, static fields are accessible from anywhere the Invoice class is accessible (both from inside and outside the class). If this is undesirable, TypeScript provides us with access modifier keywords and truly private #fields, both of which we’ll discuss below

There’s one more place where the static world appears: next to a code block. Let’s imagine that we don’t want to start with that invoice counter at 1, but instead we want to load it from an API somewhere.

ts
class Car {
// Static stuff
static nextSerialNumber: number
static generateSerialNumber() { return this.nextSerialNumber++ }
static {
// `this` is the static scope
fetch("https://api.example.com/vin_number_data")
.then(response => response.json())
.then(data => {
this.nextSerialNumber = data.mostRecentInvoiceId + 1;
})
}
// Instance stuff
make: string
model: string
year: number
serialNumber = Car.generateSerialNumber()
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
}
Try

This static block is run during class initialization, meaning when the class declaration itself is being evaluated (not to be confused with creating an instance). You might be wondering what the difference is between this, and running similar logic in top-level module scope outside of the class. We’re about to talk about how we can make fields private, and static blocks have access to private scopes.

This language feature even allows you to create something similar to what the friend keyword in C++ does — give another class declared in the same scope access to private data.

Access modifier keywords

public, private and protected

TypeScript provides three access modifier keywords, which can be used with class fields and methods, to describe who should be able to see and use them.

keyword who can access (instance field/method)
public Anyone who has access to the scope in which the instance exists
protected the instance itself, and subclasses
private only the instance itself

Let’s see how this works in the context of an example:

ts
class Car {
// Static stuff
static nextSerialNumber: number
static generateSerialNumber() { return this.nextSerialNumber++ }
static {
// `this` is the static scope
fetch("https://api.example.com/vin_number_data")
.then(response => response.json())
.then(data => {
this.nextSerialNumber = data.mostRecentInvoiceId + 1;
})
}
// Instance stuff
make: string
model: string
year: number
private _serialNumber = Car.generateSerialNumber()
protected get serialNumber() {
return this._serialNumber
}
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
}
 
class Sedan extends Car {
getSedanInformation () {
this._serialNumber
Property '_serialNumber' is private and only accessible within class 'Car'.2341Property '_serialNumber' is private and only accessible within class 'Car'.
const { make, model, year, serialNumber } = this;
return { make, model, year, serialNumber }
}
}
 
const s = new Sedan("Nissan", "Altima", 2020)
s.serialNumber
Property 'serialNumber' is protected and only accessible within class 'Car' and its subclasses.2445Property 'serialNumber' is protected and only accessible within class 'Car' and its subclasses.
Try

A couple of things to note in the example above:

  • The top-level scope doesn’t have the ability to read serialNumber anymore
  • Sedan doesn’t have direct access to write _serialNumber, but it read it through the protected getter serialNumber
  • Car can expose private functionality by defining its own protected functionality (the serialNumber getter)
  • Sedan can expose protected functionality by defining its own public functionality (the getSedanInformation() return value)

these access modifier keywords can be used with static fields and methods as well

ts
class Car {
// Static stuff
private static nextSerialNumber: number
private static generateSerialNumber() { return this.nextSerialNumber++ }
static {
// `this` is the static scope
fetch("https://api.example.com/vin_number_data")
.then(response => response.json())
.then(data => {
this.nextSerialNumber = data.mostRecentInvoiceId + 1;
})
}
// Instance stuff
make: string
model: string
year: number
private _serialNumber = Car.generateSerialNumber()
protected get serialNumber() {
return this._serialNumber
}
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
}
 
class Sedan extends Car {
getSedanInformation () {
Car.generateSerialNumber()
Property 'generateSerialNumber' is private and only accessible within class 'Car'.2341Property 'generateSerialNumber' is private and only accessible within class 'Car'.
const { make, model, year, serialNumber } = this;
return { make, model, year, serialNumber }
}
}
Try

What you may notice here is that static scopes and instance scopes have some degree of visibility. protected static fields are accessible in the class’ static and instance scopes — as well as static and instance scopes of any subclasses.

keyword who can access (static field/method)
public Anyone who has access to the scope in which the class exists
protected static and instance scopes of the class and its subclasses
private static scope instance scopes of the class only
emoji-warning Not for secret-keeping or security

It is important to understand that, just like any other aspect of type information, access modifier keywords are only validated at compile time, with no real privacy or security benefits at runtime. This means that even if we mark something as private, if a user decides to set a breakpoint and inspect the code that’s executing at runtime, they’ll still be able to see everything.

JS private #fields

As of TypeScript 3.8, TypeScript supports use of ECMAScript private class fields. If you have trouble getting this to work in your codebase, make sure to double-check your Babel settings

ts
class Car {
private static nextSerialNumber: number
private static generateSerialNumber() { return this.nextSerialNumber++ }
 
make: string
model: string
year: number
#serialNumber = Car.generateSerialNumber()
 
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
}
const c = new Car("Honda", "Accord", 2017)
c.#serialNumber
Property '#serialNumber' is not accessible outside class 'Car' because it has a private identifier.18013Property '#serialNumber' is not accessible outside class 'Car' because it has a private identifier.
Try

Unlike TypeScript’s private keyword, these are truly private fields, which cannot be easily accessed at runtime. It’s important to remember, particularly if you’re writing client side code, that there are still ways of accessing private field data through things like the Chrome Dev Tools protocol. Use this as an encapsulation tool, not as a security construct. The implementation of JS private fields is also mutually exclusive with properly-behaving ES proxies, which you may not care about directly, but it’s possible that libraries you rely on use them.

TypeScript 5 supports static private #fields

ts
class Car {
static #nextSerialNumber: number
static #generateSerialNumber() { return this.#nextSerialNumber++ }
 
make: string
model: string
year: number
#serialNumber = Car.#generateSerialNumber()
 
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
}
Try

This example is starting to make more sense now — the class-level counter is now not observable in any way from outside the class, either at build time or runtime.

Private field presence checks

Although the data held by a private field is private in a properly implemented JS runtime, we can still detect whether a private field exists without attempting to read it

ts
class Car {
static #nextSerialNumber: number
static #generateSerialNumber() { return this.#nextSerialNumber++ }
 
make: string
model: string
year: number
#serialNumber = Car.#generateSerialNumber()
 
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
equals(other: unknown) {
if (other &&
typeof other === 'object' &&
#serialNumber in other) {
other
(parameter) other: Car
return other.#serialNumber = this.#serialNumber
}
return false
}
}
const c1 = new Car("Toyota", "Hilux", 1987)
const c2 = c1
c2.equals(c1)
Try

Part of understanding what’s happening here is remembering the rules about JS private #fields and #methods. It may be true that another class has a private #invoice_id field, but instances of Invoice would not be able to read it. Thus, if #invoice_id in other evaluates to true, other must be an instance of Invoice. This is why we see the type of other change from any to Invoice after this check is performed.

readonly

While not strictly an access modifier keyword (because it has nothing to do with visibility), TypeScript provides a readonly keyword that can be used with class fields.

ts
class Car {
static #nextSerialNumber: number
static #generateSerialNumber() { return this.#nextSerialNumber++ }
 
public make: string
public model: string
public year: number
readonly #serialNumber = Car.#generateSerialNumber()
 
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
 
changeSerialNumber(num: number) {
this.#serialNumber = num
Cannot assign to '#serialNumber' because it is a read-only property.2540Cannot assign to '#serialNumber' because it is a read-only property.
}
}
Try

Param properties

Ok, let’s pop a stack frame. Now that we know about access modifier keywords, let’s return to an earlier code snippet from our discussion around class fields:

ts
class Car {
make: string
model: string
year: number
constructor(make: string, model: string, year: number) {
this.make = make
this.model = model
this.year = year
}
}
Try

TypeScript provides a more concise syntax for code like this, through the use of param properties:

ts
class Car {
constructor(
public make: string,
public model: string,
public year: number
) {}
}
 
const myCar = new Car("Honda", "Accord", 2017)
myCar.make
       
Try

This is the only time you will see an access modifier keyword next to something other than a class member. Here’s what this syntax means, conceptually:

ts
class Car {
constructor(public make: string) {}
}

The first argument passed to the constructor should be a string, and should be available within the scope of the constructor as make. This also creates a public class field on Car called make and pass it the value that was given to the constructor

It is important to understand the order in which “constructor-stuff” runs.

Here’s an example that will help us understand how this works:

ts
class Base {}
 
class Car extends Base {
foo = console.log("class field initializer")
constructor(public make: string) {
super()
console.log("custom constructor stuff")
}
}
 
const c = new Car("honda")
Try

and the equivalent compiled output:

ts
"use strict";
class Base {
}
class Car extends Base {
constructor(make) {
super();
this.make = make;
this.foo = console.log("class field initializer");
console.log("custom constructor stuff");
}
}
 
Try

Note the following order of what ends up in the class constructor:

  1. super()
  2. param property initialization
  3. other class field initialization
  4. anything else that was in your constructor after super()

Also note that, while it is possible in JS to put stuff before super(), the use of class field initializers or param properties disallows this:

ts
class Base {
constructor(){
console.log('base constructor')
}
}
 
class Car extends Base {
foo = console.log("class field initializer")
constructor(public make: string) {
console.log("before super")
super()
console.log("custom constructor stuff")
}
}
Try

Overrides

A common mistake, that has historically been difficult for TypeScript to assist with is typos when overriding a class method

ts
class Car {
honk() {
console.log("beep")
}
}
 
class Truck extends Car {
hoonk() { // OOPS!
console.log("BEEP")
}
}
 
const t = new Truck();
t.honk(); // "beep"
Try

In this case, it looks like the intent was to override the base class method, but because of the typo, we defined an entirely new method with a new name. TypeScript 5 includes an override keyword that makes this easier to spot

ts
class Car {
honk() {
console.log("beep")
}
}
 
class Truck extends Car {
override hoonk() { // OOPS!
This member cannot have an 'override' modifier because it is not declared in the base class 'Car'. Did you mean 'honk'?4117This member cannot have an 'override' modifier because it is not declared in the base class 'Car'. Did you mean 'honk'?
console.log("BEEP")
}
}
 
const t = new Truck();
t.honk(); // "beep"
Try

The error message even correctly guessed what we meant to do! There’s a compiler option called noImplicitOverride that you can enable to make sure that a correctly established override method remains an override

ts
class Car {
honk() {
console.log("beep")
}
}
 
class Truck extends Car {
honk() {
This member must have an 'override' modifier because it overrides a member in the base class 'Car'.4114This member must have an 'override' modifier because it overrides a member in the base class 'Car'.
console.log("BEEP")
}
}
 
const t = new Truck();
t.honk(); // "BEEP"
Try

look how, once the override is in place, a modification of the subclass gets our attention

ts
class Car {
logHonk() {
console.log("beep")
}
}
 
class Truck extends Car {
override honk() {
This member cannot have an 'override' modifier because it is not declared in the base class 'Car'.4113This member cannot have an 'override' modifier because it is not declared in the base class 'Car'.
console.log("BEEP")
}
}
 
const t = new Truck();
t.honk(); // "BEEP"
Try

It’s common to miss these kinds of things when refactoring, because it’s of course valid to create non-overriding methods on subclasses.



© 2023 All Rights Reserved