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:
jsTry
////////////////////////////////// JavaScript, not TypeScript //////////////////////////////////classCar {constructor(make ,model ,year ) {this.make =make this.model =model this.year =year }}letsedan = newCar ("Honda", "Accord", 2017)sedan .activateTurnSignal ("left") // not safe!newCar (2017, "Honda", "Accord") // not safe!
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:
tsTry
classCar {make : stringmodel : stringyear : numberconstructor(make : string,model : string,year : number) {this.make =make this.model =model this.year =year }}letsedan = newCar ("Honda", "Accord", 2017)Property 'activateTurnSignal' does not exist on type 'Car'.2339Property 'activateTurnSignal' does not exist on type 'Car'.sedan .("left") // not safe! activateTurnSignal newArgument of type 'number' is not assignable to parameter of type 'string'.2345Argument of type 'number' is not assignable to parameter of type 'string'.Car (2017 , "Honda", "Accord") // not safe!
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
tsTry
classCar {make : stringmodel : stringyear : numberconstructor(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`;}}constc = newCar ("Honda", "Accord", 2017);c .honk (5); // "hooooonk"
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.
tsTry
classCar {// Static stuffstaticnextSerialNumber = 100staticgenerateSerialNumber () { return this.nextSerialNumber ++ }// Instance stuffmake : stringmodel : stringyear : numberserialNumber =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 ( newCar ("Honda", "Accord", 2017))// > "Honda Accord 2017 - #100console .log ( newCar ("Toyota", "Camry", 2022))// > "Toyota Camry 2022 - #101
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.
tsTry
classCar {// Static stuffstaticnextSerialNumber : numberstaticgenerateSerialNumber () { return this.nextSerialNumber ++ }static {// `this` is the static scopefetch ("https://api.example.com/vin_number_data").then (response =>response .json ()).then (data => {this.nextSerialNumber =data .mostRecentInvoiceId + 1;})}// Instance stuffmake : stringmodel : stringyear : numberserialNumber =Car .generateSerialNumber ()constructor(make : string,model : string,year : number) {this.make =make this.model =model this.year =year }}
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:
tsTry
classCar {// Static stuffstaticnextSerialNumber : numberstaticgenerateSerialNumber () { return this.nextSerialNumber ++ }static {// `this` is the static scopefetch ("https://api.example.com/vin_number_data").then (response =>response .json ()).then (data => {this.nextSerialNumber =data .mostRecentInvoiceId + 1;})}// Instance stuffmake : stringmodel : stringyear : numberprivate_serialNumber =Car .generateSerialNumber ()protected getserialNumber () {return this._serialNumber }constructor(make : string,model : string,year : number) {this.make =make this.model =model this.year =year }}classSedan extendsCar {getSedanInformation () {this.Property '_serialNumber' is private and only accessible within class 'Car'.2341Property '_serialNumber' is private and only accessible within class 'Car'._serialNumber const {make ,model ,year ,serialNumber } = this;return {make ,model ,year ,serialNumber }}}consts = newSedan ("Nissan", "Altima", 2020)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.s .serialNumber
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 getterserialNumber
Car
can exposeprivate
functionality by defining its ownprotected
functionality (theserialNumber
getter)Sedan
can exposeprotected
functionality by defining its ownpublic
functionality (thegetSedanInformation()
return value)
these access modifier keywords can be used with static
fields and methods as well
tsTry
classCar {// Static stuffprivate staticnextSerialNumber : numberprivate staticgenerateSerialNumber () { return this.nextSerialNumber ++ }static {// `this` is the static scopefetch ("https://api.example.com/vin_number_data").then (response =>response .json ()).then (data => {this.nextSerialNumber =data .mostRecentInvoiceId + 1;})}// Instance stuffmake : stringmodel : stringyear : numberprivate_serialNumber =Car .generateSerialNumber ()protected getserialNumber () {return this._serialNumber }constructor(make : string,model : string,year : number) {this.make =make this.model =model this.year =year }}classSedan extendsCar {getSedanInformation () {Property 'generateSerialNumber' is private and only accessible within class 'Car'.2341Property 'generateSerialNumber' is private and only accessible within class 'Car'.Car .() generateSerialNumber const {make ,model ,year ,serialNumber } = this;return {make ,model ,year ,serialNumber }}}
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 |
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
tsTry
classCar {private staticnextSerialNumber : numberprivate staticgenerateSerialNumber () { return this.nextSerialNumber ++ }make : stringmodel : stringyear : number#serialNumber =Car .generateSerialNumber ()constructor(make : string,model : string,year : number) {this.make =make this.model =model this.year =year }}constc = newCar ("Honda", "Accord", 2017)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.c .#serialNumber
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
tsTry
classCar {static #nextSerialNumber: numberstatic #generateSerialNumber() { return this.#nextSerialNumber++ }make : stringmodel : stringyear : number#serialNumber =Car .#generateSerialNumber()constructor(make : string,model : string,year : number) {this.make =make this.model =model this.year =year }}
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
tsTry
classCar {static #nextSerialNumber: numberstatic #generateSerialNumber() { return this.#nextSerialNumber++ }make : stringmodel : stringyear : 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 &&typeofother === 'object' &&#serialNumber inother ) {other returnother .#serialNumber = this.#serialNumber}return false}}constc1 = newCar ("Toyota", "Hilux", 1987)constc2 =c1 c2 .equals (c1 )
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.
tsTry
classCar {static #nextSerialNumber: numberstatic #generateSerialNumber() { return this.#nextSerialNumber++ }publicmake : stringpublicmodel : stringpublicyear : numberreadonly #serialNumber =Car .#generateSerialNumber()constructor(make : string,model : string,year : number) {this.make =make this.model =model this.year =year }changeSerialNumber (num : number) {this.Cannot assign to '#serialNumber' because it is a read-only property.2540Cannot assign to '#serialNumber' because it is a read-only property.#serialNumber =num }}
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:
tsTry
classCar {make : stringmodel : stringyear : numberconstructor(make : string,model : string,year : number) {this.make =make this.model =model this.year =year }}
TypeScript provides a more concise syntax for code like this, through the use of param properties:
tsTry
classCar {constructor(publicmake : string,publicmodel : string,publicyear : number) {}}constmyCar = newCar ("Honda", "Accord", 2017)myCar .make
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 asmake
. This also creates apublic
class field onCar
calledmake
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:
tsTry
classBase {}classCar extendsBase {foo =console .log ("class field initializer")constructor(publicmake : string) {super()console .log ("custom constructor stuff")}}constc = newCar ("honda")
and the equivalent compiled output:
tsTry
"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");}}
Note the following order of what ends up in the class constructor:
super()
- param property initialization
- other class field initialization
- 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:
tsTry
classBase {constructor(){console .log ('base constructor')}}classCar extendsBase {foo =console .log ("class field initializer")constructor(publicmake : string) {console .log ("before super")super()console .log ("custom constructor stuff")}}
Overrides
A common mistake, that has historically been difficult for TypeScript to assist with is typos when overriding a class method
tsTry
classCar {honk () {console .log ("beep")}}classTruck extendsCar {hoonk () { // OOPS!console .log ("BEEP")}}constt = newTruck ();t .honk (); // "beep"
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
tsTry
classCar {honk () {console .log ("beep")}}classTruck extendsCar {overrideThis 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'?() { // OOPS! hoonk console .log ("BEEP")}}constt = newTruck ();t .honk (); // "beep"
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
tsTry
classCar {honk () {console .log ("beep")}}classTruck extendsCar {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'.() { honk console .log ("BEEP")}}constt = newTruck ();t .honk (); // "BEEP"
look how, once the override
is in place, a modification of the subclass gets our attention
tsTry
classCar {logHonk () {console .log ("beep")}}classTruck extendsCar {overrideThis 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'.() { honk console .log ("BEEP")}}constt = newTruck ();t .honk (); // "BEEP"
It’s common to miss these kinds of things when refactoring, because it’s of course valid to create non-overriding methods on subclasses.