In this chapter, we examine how class definitions work in TypeScript:
This section is a cheat sheet for class definitions in plain JavaScript.
class OtherClass {}
class MyClass1 extends OtherClass {
publicInstanceField = 1;
constructor() {
super();
}
publicPrototypeMethod() {
return 2;
}
}
const inst1 = new MyClass1();
assert.equal(inst1.publicInstanceField, 1);
assert.equal(inst1.publicPrototypeMethod(), 2);
The next sections are about modifiers
At the end, there is a table that shows how modifiers can be combined.
static
class MyClass2 {
static staticPublicField = 1;
static staticPublicMethod() {
return 2;
}
}
assert.equal(MyClass2.staticPublicField, 1);
assert.equal(MyClass2.staticPublicMethod(), 2);
#
(private)
class MyClass3 {
#privateField = 1;
#privateMethod() {
return 2;
}
static accessPrivateMembers() {
// Private members can only be accessed from inside class definitions
const inst3 = new MyClass3();
assert.equal(inst3.#privateField, 1);
assert.equal(inst3.#privateMethod(), 2);
}
}
get
(getter) and set
(setter)Roughly, accessors are prototype methods that are inherited by instances and invoked by accessing properties. There are two kinds of accessors: getters and setters.
class MyClass4 {
#name = 'Rumpelstiltskin';
/** Prototype getter */
get name() {
return this.#name;
}
/** Prototype setter */
set name(value) {
this.#name = value;
}
}
const inst5 = new MyClass4();
assert.equal(inst5.name, 'Rumpelstiltskin'); // getter
inst5.name = 'Queen'; // setter
assert.equal(inst5.name, 'Queen'); // getter
*
(generator)
class MyClass5 {
* publicPrototypeGeneratorMethod() {
yield 'hello';
yield 'world';
}
}
const inst6 = new MyClass5();
assert.deepEqual(
Array.from(inst6.publicPrototypeGeneratorMethod()),
['hello', 'world']
);
async
class MyClass6 {
async publicPrototypeAsyncMethod() {
const result = await Promise.resolve('abc');
return result + result;
}
}
const inst7 = new MyClass6();
assert.equal(
await inst7.publicPrototypeAsyncMethod(),
'abcabc'
);
const publicInstanceFieldKey = Symbol('publicInstanceFieldKey');
const publicPrototypeMethodKey = Symbol('publicPrototypeMethodKey');
class MyClass7 {
[publicInstanceFieldKey] = 1;
[publicPrototypeMethodKey]() {
return 2;
}
}
const inst8 = new MyClass7();
assert.equal(inst8[publicInstanceFieldKey], 1);
assert.equal(inst8[publicPrototypeMethodKey](), 2);
Comments:
Symbol.iterator
. But any expression can be used inside the square brackets.
Fields:
Level | Private | Code |
---|---|---|
(instance) | field |
|
(instance) | # | #field |
static | static field |
|
static | # | static #field |
Methods (columns: Level, Accessor, Async, Generator, Private, Code – without body):
Level | Acc | Async | Gen | Priv | Code |
---|---|---|---|---|---|
(prototype) | m() |
||||
(prototype) | get | get p() |
|||
(prototype) | set | set p(x) |
|||
(prototype) | async | async m() |
|||
(prototype) | * | * m() |
|||
(prototype) | async | * | async * m() |
||
(prototype-ish) | # | #m() |
|||
(prototype-ish) | get | # | get #p() |
||
(prototype-ish) | set | # | set #p(x) |
||
(prototype-ish) | async | # | async #m() |
||
(prototype-ish) | * | # | * #m() |
||
(prototype-ish) | async | * | # | async * #m() |
|
static | static m() |
||||
static | get | static get p() |
|||
static | set | static set p(x) |
|||
static | async | static async m() |
|||
static | * | static * m() |
|||
static | async | * | static async * m() |
||
static | # | static #m() |
|||
static | get | # | static get #p() |
||
static | set | # | static set #p(x) |
||
static | async | # | static async #m() |
||
static | * | # | static * #m() |
||
static | async | * | # | static async * #m() |
It’s important to keep in mind that with classes, there are two chains of prototype objects:
Consider the following plain JavaScript example:
class ClassA {
static staticMthdA() {}
constructor(instPropA) {
this.instPropA = instPropA;
}
prototypeMthdA() {}
}
class ClassB extends ClassA {
static staticMthdB() {}
constructor(instPropA, instPropB) {
super(instPropA);
this.instPropB = instPropB;
}
prototypeMthdB() {}
}
const instB = new ClassB(0, 1);
Figure 21.1 shows what the prototype chains look like that are created by ClassA
and ClassB
.
Figure 21.1: The classes ClassA
and ClassB
create two prototype chains: One for classes (left-hand side) and one for instances (right-hand side).
By default, all data slots in TypeScript are public properties. There are two ways of keeping data private:
We’ll look at both next.
Note that TypeScript does not currently support private methods.
Private properties are a TypeScript-only (static) feature. Any property can be made private by prefixing it with the keyword private
(line A):
class PersonPrivateProperty {
private name: string; // (A)
constructor(name: string) {
this.name = name;
}
sayHello() {
return `Hello ${this.name}!`;
}
}
We now get compile-time errors if we access that property in the wrong scope (line A):
const john = new PersonPrivateProperty('John');
assert.equal(
john.sayHello(), 'Hello John!'
);
// @ts-expect-error: Property 'name' is private and only accessible
// within class 'PersonPrivateProperty'.
john.name; // (A)
However, private
doesn’t change anything at runtime. There, property .name
is indistinguishable from a public property:
assert.deepEqual(
Object.keys(john),
['name']
);
We can also see that private properties aren’t protected at runtime when we look at the JavaScript code that the class is compiled to:
class PersonPrivateProperty {
constructor(name) {
this.name = name;
}
sayHello() {
return `Hello ${this.name}!`;
}
}
Private fields are a new JavaScript feature that TypeScript has supported since version 3.8:
class PersonPrivateField {
#name: string;
constructor(name: string) {
this.#name = name;
}
sayHello() {
return `Hello ${this.#name}!`;
}
}
This version of Person
is mostly used the same way as the private property version:
const john = new PersonPrivateField('John');
assert.equal(
john.sayHello(), 'Hello John!'
);
However, this time, the data is completely encapsulated. Using the private field syntax outside classes is even a JavaScript syntax error. That’s why we have to use eval()
in line A so that we can execute this code:
assert.throws(
() => eval('john.#name'), // (A)
{
name: 'SyntaxError',
message: "Private field '#name' must be declared in "
+ "an enclosing class",
}
);
assert.deepEqual(
Object.keys(john),
[]
);
Compiled to JavaScript, PersonPrivateField
looks more or less the same:
class PersonPrivateField {
#name;
constructor(name) {
this.#name = name;
}
sayHello() {
return `Hello ${this.#name}!`;
}
}
Private fields and private properties can’t be accessed in subclasses (line B):
class PrivatePerson {
private name: string;
constructor(name: string) {
this.name = name;
}
sayHello() {
return `Hello ${this.name}!`;
}
}
class PrivateEmployee extends PrivatePerson {
private company: string;
constructor(name: string, company: string) {
super(name);
this.company = company;
}
override sayHello() { // (A)
// @ts-expect-error: Property 'name' is private and only
// accessible within class 'PrivatePerson'.
return `Hello ${this.name} from ${this.company}!`; // (B)
}
}
The keyword override
is explained later – it’s for methods that override super-methods.
We can fix the previous example by switching from private
to protected
in line A (we also switch in line B, for consistency’s sake):
class ProtectedPerson {
protected name: string; // (A)
constructor(name: string) {
this.name = name;
}
sayHello() {
return `Hello ${this.name}!`;
}
}
class ProtectedEmployee extends ProtectedPerson {
protected company: string; // (B)
constructor(name: string, company: string) {
super(name);
this.company = company;
}
override sayHello() {
return `Hello ${this.name} from ${this.company}!`; // OK
}
}
At the moment, JavaScript does not support hash-private constructors. However, TypeScript supports private
for them. That is useful when we have static factory methods and want clients to always use those methods, never the constructor directly. Static methods can access private class members, which is why the factory methods can still use the constructor.
In the following code, there is one static factory method DataContainer.create()
. It sets up instances via asynchronously loaded data. Keeping the asynchronous code in the factory method enables the actual class to be completely synchronous:
class DataContainer {
#data: string;
static async create() {
const data = await Promise.resolve('downloaded'); // (A)
return new this(data);
}
private constructor(data: string) {
this.#data = data;
}
getData() {
return 'DATA: '+this.#data;
}
}
const dataContainer = await DataContainer.create();
assert.equal(
dataContainer.getData(),
'DATA: downloaded'
);
In real-world code, we would use fetch()
or a similar Promise-based API to load data asynchronously in line A.
The private constructor prevents DataContainer
from being subclassed. If we want to allow subclasses, we have to make it protected
.
If the compiler setting --strictPropertyInitialization
is switched on (which is the case if we use --strict
), then TypeScript checks if all declared instance properties are correctly initialized:
Either via assignments in the constructor:
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
Or via initializers for the property declarations:
class Point {
x = 0;
y = 0;
// No constructor needed
}
However, sometimes we initialize properties in a manner that TypeScript doesn’t recognize. Then we can use exclamation marks (definite assignment assertions) to switch off TypeScript’s warnings (line A and line B):
class Point {
x!: number; // (A)
y!: number; // (B)
constructor() {
this.initProperties();
}
initProperties() {
this.x = 0;
this.y = 0;
}
}
In the following example, we also need definite assignment assertions. Here, we set up instance properties via the constructor parameter props
:
class CompilerError implements CompilerErrorProps { // (A)
line!: number;
description!: string;
constructor(props: CompilerErrorProps) {
Object.assign(this, props); // (B)
}
}
// Helper interface for the parameter properties
interface CompilerErrorProps {
line: number,
description: string,
}
// Using the class:
const err = new CompilerError({
line: 123,
description: 'Unexpected token',
});
Notes:
Object.assign()
to copy the properties of parameter props
into this
.
implements
ensures that the class declares all properties that are part of interface CompilerErrorProps
.
tsc
can infer the type of the member .str
because we assign to it in line A. However, that is not compatible with the compiler option isolatedDeclarations
(which enables external tools to generate declarations without doing inference):
class C {
str;
constructor(str: string) {
this.str = str; // (A)
}
}
public
, private
or protected
JavaScript currently has no equivalent to the TypeScript feature described in this subsection – which is why it is illegal if the compiler option erasableSyntaxOnly
is active.
If we use the modifier public
for a constructor parameter prop
, then TypeScript does two things for us:
.prop
.
prop
to that instance property.
This is an example:
class Point {
constructor(public x: number, public y: number) {
}
}
If we use private
or protected
instead of public
, then the corresponding instance properties are private or protected.
The TypeScript class Point
is compiled to the following JavaScript code:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
Two constructs can be abstract in TypeScript:
The following code demonstrates abstract classes and methods.
On one hand, there is the abstract superclass Printable
and its helper class StringBuilder
:
class StringBuilder {
string = '';
add(str: string) {
this.string += str;
}
}
abstract class Printable {
toString() {
const out = new StringBuilder();
this.print(out);
return out.string;
}
abstract print(out: StringBuilder): void;
}
On the other hand, there are the concrete subclasses Entries
and Entry
:
class Entries extends Printable {
entries: Entry[];
constructor(entries: Entry[]) {
super();
this.entries = entries;
}
print(out: StringBuilder): void {
for (const entry of this.entries) {
entry.print(out);
}
}
}
class Entry extends Printable {
key: string;
value: string;
constructor(key: string, value: string) {
super();
this.key = key;
this.value = value;
}
print(out: StringBuilder): void {
out.add(this.key);
out.add(': ');
out.add(this.value);
out.add('\n');
}
}
And finally, this is us using Entries
and Entry
:
const entries = new Entries([
new Entry('accept-ranges', 'bytes'),
new Entry('content-length', '6518'),
]);
assert.equal(
entries.toString(),
'accept-ranges: bytes\ncontent-length: 6518\n'
);
Notes about abstract classes:
override
for methodsThe keyword override
is for methods that override methods in superclasses – e.g.:
class A {
m(): void {}
}
class B extends A {
// `override` is required
override m(): void {} // (A)
}
If the compiler option noImplicitOverride
is active then TypeScript complains if there is no override
in line A.
We can also use override
when we implement an abstract method. That’s not required but I find it useful information:
abstract class A {
abstract m(): void;
}
class B extends A {
// `override` is optional
override m(): void {}
}
In JavaScript, we don’t have to use classes, we can also use objects directly. TypeScript supports both approaches.
Counter
This is a class that implements a counter:
class Counter {
count = 0;
inc(): void {
this.count++;
}
}
// Trying out the functionality
const counter = new Counter();
counter.inc();
assert.equal(
counter.count, 1
);
Counter
In TypeScript, a class defines both a type and a factory for instances. In the following code, both are separate: We have the object type Counter
and the factory createCounter()
.
type Counter = {
count: number,
};
function createCounter(): Counter {
return {
count: 0,
};
}
function inc(counter: Counter): void {
counter.count++;
}
// Trying out the functionality
const counter = createCounter();
inc(counter);
assert.equal(
counter.count, 1
);
Benefits of classes:
inc
.count
has the default value 0.
instanceof
– e.g. to narrow a type.
Benefits of object types:
structuredClone()
does not preserve the class of an instance.
Serializing and deserializing (to/from JSON etc.) is an interesting use case:
JSON.parse()
(potentially after validating the type via Zod).
Map
. Then classes have one benefit: We can customize serialization by implementing the method .toJSON()
.
Apart from these criteria, which one to choose depends on whether you prefer code that is more object-oriented or code that is more functional.
We have not covered inheritance – where you also have a choice between an object-oriented coding style (classes) and a functional coding style (discriminated unions). For more information, see “Class hierarchies vs. discriminated unions” (§19.3).