Essential Typescript
Essential Typescript
Personal Summary
Patrick Bucher
2024-12-25
Contents
1 Understanding TypeScript 2
8 Using Functions 51
1
9 Using Arrays, Tuples, and Enums 59
9.1 Tuples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60
9.2 Enums . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60
9.3 Literal Value Types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
9.4 Type Aliases . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
14 Using Decorators 97
This document is a personal and partial summary of Essential TypeScript (3rd Edition) by Adam
Freeman. Some examples have been retained, some have been modified, and some have been
made up.
1 Understanding TypeScript
TypeScript is a superset of JavaScript. It adds static typing to produce safe and predictable code. It
also provides convenience features such as a concise class constructor syntax, and access control
keywords. Some TypeScript features are implemented entirely by the compiler,leaving no trace of
TypeScript in the resulting JavaScript code.
Working effectively with TypeScript requires understanding JavaScript and its type system. Type-
Script allows using modern language features in code that is transformed for execution in older
JavaScript runtimes, although not every new feature can be translated to older runtimes.
2
2 Your First TypeScript Application
The TypeScript compiler tsc can be installed using the Node Package Manager (NPM), which is
distributed with Node.js. Install it from nodejs.org and make sure to run Node version 18 and NPM
version 8 or later:
$ node --version
v23.1.0
$ npm --version
10.9.0
The TypeScript compiler can be installed (globally, i.e. for all of the current user’s projects) in a
specific version, e.g. 5.6.3 as follows:
It’s also recommended to install Git from git-scm.org and a programmer’s text editor such as Vi-
sual Studio Code from code.visualstudio.com.
To create a new project (e.g. race), create a folder, navigate into it, and initialize a Node.js project
with default settings:
$ mkdir race
$ cd race
$ npm init --yes
A file package.json has been created, which keeps track of the project’s settings and package de-
pendencies.
Tye TypeScript code shall be placed in the src/ subfolder and be copiled to pure JavaScript code
into the dist/ subfolder. Create a file tsconfig.json for this purpose:
{
"compilerOptions": {
"target": "ES2023",
"outDir": "./dist",
"rootDir": "./src",
"module": "CommonJS"
}
}
3
This configuration produces JavaScript code according to the ECMAScript2023 specification. The
module setting defines which module system is being used in the emitted JavaScript code, not the
module system being used in the TypeScript code! CommonJS is used here, because the resulting
code shall be run in Node.js.
The main code file is commonly named index.ts, which is to be placed into the src/ folder:
console.clear();
console.log("Hello, World!");
$ tsc
$ node dist/index.js
Hello, World!
As an example application, multiple drivers entering a race shall be modeled in TypeScript. The
driver is represented as a class called Driver, which is defined in driver.ts:
• Fields (id, name, retired) and methods (describe) are annotated with a type (number, string,
boolean), which is written as a suffix after the colon :.
4
• Fields and methods also have access modifiers (public).
TypeScript provides a shorter syntax to initialize the fields of a class that are passed to its construc-
tor. The Driver class can be writter more concisely without loosing any functionality as follows:
public constructor(
public id: number,
public name: string,
public retired: boolean = false,
) {
// no manual initialization required
}
The field declaration and initialization is automatically handled by the constructor, whose param-
eters now also come with access modifiers. The default access modifier is public, but here it must
be defined for the constructor parameters so that the compiler detects that concise constructor
syntax is being used.
Multiple drivers shall be put together in a container class called Race, which is defined in
race.ts:
constructor(
public track: string,
public laps: number,
public drivers: Driver[] = [],
) {}
5
return newId;
}
The drivers are stored in an array that is annotated with the type Driver[]—an array of drivers.
Those two classes—Driver and Race—can interact together as shown in index.ts:
const drivers = [
new Driver(1, "Freddie Fuel"),
new Driver(2, "Eddie Engine"),
new Driver(3, "Walter Wheel"),
];
const race = new Race("Detroit City Speedwary", 48, drivers);
6
4 Tommy Tardy [ ]
There are no type annotations used in the code of index.ts. The TypeScript compiler is able to
infer the proper types by the context. However, the code can be made easier to read by providing
additional type annotations. The programmer can decide to what extent type annotations shall
be used to give both the compiler and other programmers hints on the types being used.
The code of index.ts can be rewritten as follows with additional type annotations:
Instead of storing the drivers in an array and looking them up by filtering for their id, storing them
in a Map will make the lookup easier and faster. The values of driverMap can be filtered like an array
(race.ts):
constructor(
public track: string,
public laps: number,
drivers: Driver[] = [],
) {
drivers.forEach((d) => this.driverMap.set(d.id, d));
}
7
addDriver(name: string, retired: boolean = false): number {
const maxId = Math.max(...this.driverMap.keys());
const newId = maxId + 1;
this.driverMap.set(newId, new Driver(newId, name, retired));
return newId;
}
The otherwise dynamic types of a JavaScript Map are restricted for driverMap with the type param-
eter number for keys and Driver for values, which are defined in angle brackets. Those type hints
allow the compiler to know and check the value type of the Map: Driver, which has a field retired
of type boolean.
Retired drivers can be removed from the map by adding the method removeRetired (race.ts):
removeRetired() {
this.driverMap.forEach((d) => {
if (d.retired) {
this.driverMap.delete(d.id);
}
});
}
The types of object literals can be described using an object’s shape: a combination of the property
names and types, which can be declared as a new type alias:
8
type DriverCounts = {
total: number;
active: number;
};
A method to return those counts can be annotated with DriverCounts as its return type:
getDriverCounts(): DriverCounts {
return {
total: this.driverMap.size,
active: this.getDrivers(false).length,
};
}
Output:
1 Freddie Fuel [ ]
3 Walter Wheel [ ]
4 Tommy Tardy [ ]
{ total: 4, active: 3 } before removing retired
{ total: 3, active: 3 } after removing retired
9
2.2 Using a Third‐Party Package
TypeScript allows using any JavaScript package with additional static type support. In order to
make use of ECMAScript modules, which is the common standard for modules by now, the Node.js
project configuration file package.json has to be extended by the following setting:
"type": "module"
The TypeScript compiler settings in tsconfig.json have to be modified accordingly, so that the
module system defined by the Node.js project is considered:
"module": "Node16"
The way in wh ich Node.js implements ECMAScript modules requires to use the .js (not .ts!)
extension for imports, e.g. in race.ts:
This has to be understood as a reference to the compiled JavaScript file as opposed to the original
TypeScript source code file.
The existing application shall be extended by the Inquirer.js library to provide interactive
menus. Pure JavaScript libraries can be installed into a TypeScript project as in a plain JavaScript
project:
Since the Inquirer.js project doesn’t provide any type information, the TypeScript compiler cannot
check its proper usage concerning data types. There are, however, two ways in which such type
information can be provided still: First, to describe the types yourself; and second, to use existing
type declarations from the Typed project—a repository of type declarations for many JavaScript
packages. Such type declarations can be installed as a development dependency as follows (in a
slightly different version, though):
The Inquirer.js package shall be used to implement an interactive menu. This menu provides dif-
ferent options using different kinds of prompts, e.g. to enter additional drivers to a race, or to mark
drivers as retired, which then can be purged from the list.
Each command is represented as an entry in an enum, which is a TypeScript feature to group related
constants together:
10
enum Commands {
Add = "Add New Driver",
Retire = "Retire a Driver",
Toggle = "Show/Hide Retired Drivers",
Purge = "Remove Retired Drivers",
Quit = "Quit",
}
The menu is run by the promptUser function, which dispatches individual commands to their re-
spective prompt function—or performs the action on its own:
11
The inquirer.prompt function shows a prompt that is configured using a JavaScript object with
the following properties:
• type: "list": shows the provided choices (see below) as an interactive menu
• name: "commands": assigns a name to the property that will hold the user’s choice
• message: "Choose Option": is the actual prompt being shown to the user
• choices: Object.values(Commnds): provides the options for the user to select from a list
The other prompt functions use a different type together with different options for their user in-
teraction:
12
race
.getDrivers(true)
.forEach((driver) =>
race.markRetired(
driver.id,
retiredDrivers.find((id) => id === driver.id) != undefined,
),
);
promptUser();
});
}
Since the compiler cannot figure out the type of answers["retired"], the type assertion as number[]
is used to explicitly tell the compiler that an array of numbers is being used.
The list of drivers is shown using the displayDrivers function:
The application shall be extended with persistent storage of the data. For this purpose the Lowdb
package shall be used, which stores data in JSON files. Even though this is a pure JavaScript pack-
age, it supplies type information:
Persistence shall be added by the means of inheritance: The Race class—with its various opera-
tions that manipulate the data—is extended to synchronize the changes with the JSON database
on the disk. Currently, driverMap is declared as private, preventing sub-classes from accessig it.
To allow this access, driverMap has to be declared as protected (race.ts):
13
import { Driver } from "./driver.js";
import { Race } from "./race.js";
import { LowSync } from "lowdb";
import { JSONFileSync } from "lowdb/node";
type schemaType = {
drivers: {
id: number;
name: string;
retired: boolean;
}[];
};
constructor(
public track: string,
public laps: number,
drivers: Driver[] = [],
) {
super(track, laps, []);
this.database = new LowSync(new JSONFileSync("race.json"));
this.database.read();
if (this.database.data == null) {
this.database.data = { drivers: drivers };
this.database.write();
drivers.forEach((driver) => this.driverMap.set(driver.id, driver));
} else {
this.database.data.drivers.forEach((driver) =>
this.driverMap.set(
driver.id,
new Driver(driver.id, driver.name, driver.retired),
),
);
}
}
14
markRetired(id: number, retired: boolean): void {
super.markRetired(id, retired);
this.storeDrivers();
}
removeRetired(): void {
super.removeRetired();
this.storeDrivers();
}
private storeDrivers() {
this.database.data.drivers = [...this.driverMap.values()];
this.database.write();
}
}
The schema used for storing the data persistently is defined using a type called schemaType, which
describes the shape of the object to be stored: an array of drivers.
The extended implementation can be used as follows (index.ts):
To understand the benefits provided by TypeScript, one has to understand what JavaScript issues
it addresses.
For the sake of convenient demonstration with automatic execution of a script upon saving it, the
nodemon package can be used in a new project called primer:
mkdir primer
cd primer
npm init --yes
npm install nodemon@3.1.7
touch index.js
npx nodemon index.js
JavaScript is similar to many other programming languages in many ways, but it confuses its users
with some of its—peculiar, but well-defined—behaviour:
15
let penPrice = 5;
let paperPrice = "5";
if (penPrice == paperPrice) {
console.log("pen and paper cost the same");
}
console.log(`total price: ${penPrice + paperPrice}`);
Output:
The type of an expression can be determined by using the typeof operator on it, which returns the
type name as a string:
> typeof 5
'number'
> typeof "5"
'string'
> typeof (typeof 5)
'string'
> typeof null
'object'
Notice the last example: The type of null is object instead of null. This behaviour is inconsistent,
but cannot be changed because a lot of code depends on this (mis)behaviour.
16
3.1 Type Coercion
An operator being applied to two values of different types needs to coerce one value to the type
of the other value. Different operators apply different rules for this process, which is called type
coercion: The == operator applied to a number and a string converts the string to a number and then
compares the two number values:
> 3 == "5"
false
> 3 == "3"
true
However, the + operator applied to a string and a value of any other type will first convert the
other value to a string and then concatenate both string values:
> "3" + 4
'34'
> 4 + "3"
'43'
When aplied to a number value and undefined, the latter is converted to NaN, resulting in an addition
that results in NaN itself:
> 3 + undefined
NaN
Such behaviour is erratic, but well-defined and documented. To solve problems arising from those
rules, the strict equality operator === can be used instead of ==. Here, both value and type must
match:
> 3 == "3"
true
> 3 === "3"
false
To make sure that numbers are added instead of concatenated, use explicit type conversion:
17
> let x = 3;
> let y = 4;
> let z = "5";
> const addNumbers = (a, b) => Number(a) + Number(b);
> addNumbers(x, y);
7
> addNumbers(y, z);
9
Type coercion can be very useful, too: The or-operator || converts null and undefined to false,
which allows for defining fallback values:
Unfortunately, not only null and undefined are coerced into false, but also the empty string "",
the number 0, and NaN:
Here, the fallback to the default value is not desired, because 0 is a perfectly fine value in this
context. The nullish coalescing operator ??, which is a rather recent addition to JavaScript, only
converts null and undefined to false:
3.2 Functions
Function parameters are untyped, and argument values will be coerced as needed based on the
operators being applied to them. Parameters can be given default values so that they don’t end
up being undefined when the function is called with fewer arguments than specified:
18
> const formatCurrency = (currency, amount = 0.0) => `${currency} ${amount}`;
> formatCurrency("CHF", 3.5);
'CHF 3.5'
> formatCurrency("CHF");
'CHF 0'
Values such as undefined and NaN being passed explicitly have to be dealt with programmati-
cally:
function mean(...numbers) {
let actualNumbers = numbers.map((x) => (Number.isNaN(x) ? 0 : Number(x)));
let sum = actualNumbers.reduce((acc, x) => acc + x, 0);
return sum / actualNumbers.length;
}
3.3 Arrays
JavaScript arrays are dynamically sized and can take up elements of different types. They support
various operations:
19
– includes(value): returns true if the array contains value; false otherwise
• higher-order functions
– every(predicate): returns true if predicate returns true for all elements
– some(predicate): returns true if predicate returns true for at least one element
– filter(predicate): returns an array consisting of the elements for which predicate
returns true
– find(predicate): returns the first value for which predicate returns true
– findIndex(predicate): returns the first index of the value for which predicate returns
true
– forEach(callback): calls the callback function on each element
– map(callback): returns an array consisting of the return values of callback called on
every element
– reduce(callback, start): combines the array elements using the callback function
and an optional start value
Examples:
20
true
> [1, 1, 2, 3, 5, 8, 13, 21].filter(x => x % 2 == 0)
[ 2, 8 ]
> [1, 1, 2, 3, 5, 8, 13, 21].find(x => x % 2 == 0)
2
> [1, 1, 2, 3, 5, 8, 13, 21].findIndex(x => x % 2 == 0)
2
> [1, 1, 2, 3, 5, 8, 13, 21].forEach(x => console.log(`x=${x}`))
x=1
x=1
x=2
x=3
x=5
x=8
x=13
x=21
> [1, 1, 2, 3, 5, 8, 13, 21].map(x => x * 2)
[ 2, 2, 4, 6, 10, 16, 26, 42 ]
> [1, 1, 2, 3, 5, 8, 13, 21].reduce((acc, x) => acc + x, 0)
54
An array can be passed to a function expecting rest parameters by applying the spread operator
... to it:
function sumUp(...numbers) {
return numbers.reduce((acc, x) => acc + x, 0);
}
21
let [, , third] = words;
let [, , ...lastTwo] = words;
console.log(`first: ${first}`);
console.log(`second: ${second}`);
console.log(`third: ${third}`);
console.log(`lastTwo: ${lastTwo}`);
Output:
first: read
second: write
third: think
lastTwo: think,morning
3.4 Objects
JavaScript objects are collections of properties, which have a name and a value. Objects can be
expressed using a literal syntax:
Properties can be accessed using the dot operator, added by assignment, and removed using the
delete keyword:
Reading a property that doesn’t exist returns undefined. The optional chaining operator ?. will stop
the evaluation once null or undefined is reached. This is especially useful in combination with the
?? operator:
> bob?.place?.population ?? 0;
0
22
> let { name, age } = bob;
> name
'Bob'
> age
46
> JSON.stringify(bob);
'{"name":"Bob","age":46}'
> JSON.stringify(alice);
'{"age":52,"place":"Sweden"}'
let stock = {
item: "Beer",
_price: 1.25,
_quantity: 100,
set price(newPrice) {
this._price = newPrice;
},
get price() {
return this._price;
},
set quantity(newQuantity) {
this._quantity = newQuantity;
},
get quantity() {
return this._quantity;
},
23
get worth() {
return this._price * this._quantity;
},
};
Output:
• The properties price and quanitity have getter and setter methods; values can be read from
them and be assigned to them. They are backed internal properties _price and _quantity.
• The property worth is a computed property that only has a get method and therefore cannot
be overwritten.
3.4.1 this
this refers to different objects depending on how a function or method using it is called. Consider
a function that outputs a label and a value:
function output(value) {
console.log(`${this.label}=${value}`);
}
label = "x";
output(13);
Output:
x=13
The this object refers to the global object by default. Properties can be set to the global object by
simple assignment (as label = "x" above) without using the var, let, or const keyword—except
in strict mode!
Functions and methods are objects in JavaScript, which have their own properties and methods
in turn. The above function call is actually a convenience syntax for the following invocation:
24
output.call(global, 13);
The global object is called global in Node.js and window or self in a browser context; the latter
having an object called document representing the DOM.
When a function belongs to an object and is invoked as a method, the this keyword refers to the
surrounding object:
let object = {
label: "y",
output(value) {
console.log(`${this.label}=${value}`);
},
};
label = "x";
object.output(13); // same as: object.output.call(object, 13);
Output:
y=13
However, if the method is called outside of its object context, this refers to the global object:
let object = {
label: "y",
output(value) {
console.log(`${this.label}=${value}`);
},
};
label = "x";
let output = object.output;
output(13); // same as: output.call(global, 13);
Output:
x=13
The this keyword can be bound explitly and persistently to an object using the method’s bind
method:
25
let object = {
label: "y",
output(value) {
console.log(`${this.label}=${value}`);
},
};
label = "x";
object.output = object.output.bind(object);
object.output(13);
let output = object.output;
output(13);
Output:
y=13
y=13
Now output being called as a stand-alone function also uses object as its this reference:
this.label has the value of "y" in both cases.
An arrow function returned from a method works differently in respect to its this reference. Con-
sider a following example, in which the function creating the output is an arrow function being
returned from a method:
let object = {
label: "y",
getOutput() {
return (value) => console.log(`${this.label}=${value}`);
},
};
label = "x";
Output:
26
y=11
x=11
In the first usage, the getOutput is called in the context of object, binding this to object. In the
second usage, the getOutput method is called in the global context, binding this to global.
An arrow functio has no this reference of its own! It instead works its way up the scope until it
finds a this reference instead—either reaching the surrounding or the global object.
A JavaScript object inherits its properties and methods from another object known as its prototype.
The links of prototypes form an inheritance chain. By default, an object defined by a literal has the
prototype Object, which defines the following methods related to prototypes:
The following example defines two objects and checks if they share the same prototype:
let alice = {
name: "Alice",
age: 52,
};
let bob = {
name: "Bob",
age: 47,
};
Output:
27
It is possible to define operations shared among objects directly on its default prototype Object:
let alice = {
name: "Alice",
age: 52,
};
let bob = {
name: "Bob",
age: 47,
};
aliceProto.toString = function () {
return `${this.name} is ${this.age} years old.`;
};
console.log(`alice: ${alice}`);
console.log(`bob: ${bob}`);
let product = {
name: "Candy Bar",
price: 1.25,
};
console.log(`product: ${product}`);
Output:
The method toString is overwritten directly on the prototype of the object alice, which is Object.
Therefore, toString is also overwritten for the object product, which has no age property.
A better option is to define a common and custom prototype shared among the relevant objects
explicitly, but leaving Object untouched:
let alice = {
name: "Alice",
age: 52,
};
28
let bob = {
name: "Bob",
age: 47,
};
let ProtoPerson = {
toString: function () {
return `${this.name} is ${this.age} years old.`;
},
};
Object.setPrototypeOf(alice, ProtoPerson);
Object.setPrototypeOf(bob, ProtoPerson);
console.log(`alice: ${alice}`);
console.log(`bob: ${bob}`);
let product = {
name: "Candy Bar",
price: 1.25,
};
console.log(`product: ${product}`);
Output:
Objects can not only be created using literal syntax, but also with constructor functions, which can
apply additional logic upon the object’s creation. Constructor functions have capitalized names
by convention and are invoked using the new keyword, setting the this parameter to the newly
instantiated object.
The constructor function’s prototype property provides access to its prototype object, to which
methods can be attached:
29
};
Person.prototype.toString = function () {
return `${this.name} is ${this.age} years old.`;
};
console.log(alice.toString());
console.log(bob.toString());
Output:
Constructor functions can be chained by connecting their prototypes. A constructor further down
the chain can invoke the constructor function higher up the chain using its call method:
Person.prototype.toString = function () {
return `${this.name} is ${this.age} years old`;
};
Employee.prototype.toString = function () {
let salary = this.percentage * 0.01 * this.salary;
return `${Person.prototype.toString.call(this)} and earns ${salary}`;
};
Object.setPrototypeOf(Employee.prototype, Person.prototype);
30
console.log(alice.toString());
console.log(bob.toString());
Output:
The toString method of the prototype further up the chain has to be invoked explicitly using Per-
son.prototype.toString.call, passing it the this reference of the calling object.
The instanceof operator determines whether or not an object is part of a prototype chain. Using
the example from above:
Output:
Person.output(alice, bob);
4.2 Classes
Recent versions of JavaScript support classes, which are implemented using prototypes under-
neath. Keywords such class, extends, constructor, super, and static known from mainstream
object-oriented languages such as C# and Java are mere syntactic sugar for the concepts described
above with prototypes. Private members are created with a # prefix.
The example implementing an inheritance relationship between Person and Employee from before
can be expressed using classes as follows:
31
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
toString() {
return `${this.name} is ${this.age} years old`;
}
static output(...people) {
people.forEach((p) => console.log(p.toString()));
}
}
toString() {
return `${super.toString()} and earns ${this.#salary()}`;
}
#salary() {
return this.percentage * (this.salary / 100.0);
}
}
Output:
Notice that for bob the toString method of Employee is called, even though the static output
method is defined on Person.
32
4.3 Iterators and Generators
An iterator provides a function called next, which returns a sequence of objects containing a value
and a done property, the latter indicating whether or not the sequence has been exhausted:
class Sequence {
constructor(step, n) {
this.step = step;
this.n = n;
this.value = 0;
this.i = 0;
}
next() {
this.value += this.step;
this.i++;
return {
value: this.value,
done: this.i > this.n,
};
}
}
Output:
6
12
18
24
30
36
42
48
54
60
33
A generator is a function declared using an asterisk character (*) that returns an intermediate
result using the yield keyword and l ater continues its execution upon the next call. The state is
maintained implicitly by the runtime rather than explicitly by the programmer.
The number sequence from above can be expressed using a generator as follows:
function* createSequence(step, n) {
let value = 0;
for (let i = 0; i < n; i++) {
value += step;
yield value;
}
}
Output:
6
12
18
24
30
3
6
9
12
For objects that provide sequences of items, a special proprty called Symbol.iterator can be pro-
vided as a generator, allowing the object being used as a sequence in loops and with spread oper-
ations:
class Sequence {
constructor(step, n) {
this.step = step;
this.n = n;
}
*[Symbol.iterator]() {
let value = 0;
34
for (let i = 0; i < this.n; i++) {
value += this.step;
yield value;
}
}
}
Output:
7
14
21
28
35
10
20
30
40
50
4.4 Collections
An object’s properties are key/value pairs. The keys and values of an object called obj can be ob-
tained using the methods Object.keys(obj) and Object.values(obj), respectively. Given an ob-
ject and one of its keys, the value can be obtained using square bracket notation: let value =
obj[key].
let mouse = {
name: "Pixie",
legs: 4,
food: "cheese",
};
Output:
35
name: Pixie
legs: 4
food: cheese
Objects only support strings for keys. The Map type is more general in as far as it allows for any
types to be used as a key. A Map provides the following operations (among others):
Using a Symbol as a key avoids collisions, which could happen when the keys are derived from the
stored value:
class Person {
constructor(name, age) {
this.id = Symbol();
this.name = name;
this.age = age;
}
}
Note that a Symbol is rather abstract and not intended for output:
A Set stores unique values and supports, among others, the following operations:
36
• has(value): returns true, if value is contained in the set, and false otherwise
• size: returns the number of elements in the set
let additions = [
[3, 5],
[1, 4],
[4, 4],
[2, 1],
];
Both the additions of [3, 5] and [4, 4] result in the sum of 8, but this particular result is stored
only once in the set of sums.
Output:
8
5
3
4.5 Modules
Modules allow it to break an application into manageable junks. Most JavaScript projects use
either one of the following module systems:
1. ECMAScript modules are the official standard built into recent runtimes.
2. CommonJS modules are provided by Node.js and used to be the de facto standard.
Since ECMAScript modules can deal with CommonJS modules, most projects targeting recent run-
time versions should use ECMAScript modules. In Node.js, the type of module can be configured
by convention or by configuration:
37
– Use the "type": "commonjs" setting for CommonJS modules.
The following module defines functions for rounding values with different granularities
(round.js):
Functions defined in modules are private to the module by default and need to be made available
to other modules using the export keyword. The default keyword denotes a single feature of the
module that is imported by default without having to use an explicit name.
The round.js module can be used in index.js as follows:
The default feature of the module is imported using an alternative name: round instead of roundTo.
The other two functions are imported using their names.
If the module code resides in the same project, relative paths are used, starting with ./ for modules
located in the same directory, or starting with ../ for modules located in a directory higher up in
the hierarchy.
If external modules are used, such as those located in node_modules, the import path starts with
the module name: the name of the module directory located in node_modules.
38
5 Using the TypeScript Compiler
To demonstrate the usage of the TypeScript compiler, a new project called tools shall be cre-
ated:
mkdir tools
cd tools
npm init --yes
Two dependencies—the TypeScript compiler and a tool for automatic compilation—shall be in-
stalled:
This will store the specified dependencies in the devDependencies section of the package.json file—
unlike dependencies installed without the --save-dev option, which are listed in the dependencies
section.
A basic compiler configuration file tsconfig.json shall be created:
{
"compilerOptions": {
"target": "ES2022",
"outDir": "./dist",
"rootDir": "./src"
}
}
TypeScript files located in the src folder will be compiled using the ECMAScript 2022 standard,
and the resulting JavaScript code will be put into the dist folder.
A file src/index.ts shall be created:
greet("TypeScript");
tsc
node dist/index.js
Output:
39
Hello, TypeScript!
Version numbers in packge.json (both in dependencies and devDependencies) can be specified us-
ing different rules:
The packag.json and package-lock.json files shall be included in, the folder node_modules shall be
excluded from version control
To control which files shall be compiled when running tsc, different options can be specified in
tsconfig.json:
By default, the TypeScript compiler emits JavaScript code even when it encounters errors. This
resulting code contains potential errors. To demonstrate this problematic behaviour, extend in-
dex.ts with the following function call:
40
function greet(whom: string): void {
console.log(`Hello, ${whom}!`);
}
greet("TypeScript");
greet(100);
$ tsc
src/index.ts:6:7 - error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'
function greet(whom) {
console.log(`Hello, ${whom}!`);
}
greet("TypeScript");
greet(100);
In this case, the resulting code is unproblematic, because numbers can be printed just as strings.
However, this defeats the purpose of using TypeScript. This behaviour can be changed by setting
the noEmitOnError setting to true in tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"outDir": "./dist",
"rootDir": "./src",
"noEmitOnError": true
}
}
tsc --watch
However, the emitted JavaScript code still needs to be executed manually. Use the tsc-watch pack-
age instealled earlier for automatic execution after compilation:
To avoid typing this command again at the beginning of the next session, encode it as a script in
package.json:
41
{
"name": "tools",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "tsc-watch --onsuccess 'node dist/index.js'"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"tsc-watch": "^6.2.1",
"typescript": "^5.6.3"
}
}
npm start
To specify the targeted version of the JavaScript language being emitted by the compiler, set the
target option in tsconfig.json. By default ES5 is used. See the documentation of the target op-
tion for all allowed values. The other compiler options are documented on the TSConfig Reference
page.
If a feature is used in TypeScript code that isn’t available in the targeted JavaScript runtime, the
compiler will report an error. The problem can be resolved by either targeting a later JavaScript
standard or by changing the type definitions using the lib setting in tsconfig.json, which is an
array of library names.
To target different type of module implementations, use the module setting in tsconfig.json.
See the list of compiler options to further control the compilation process.
Because the written TypeScript code and the emitted JavaScript code do not correlate on a line-
by-line basis, source maps have to be generated in order to use a debugger on the TypeScript code.
Set the sourceMap setting to true in tsconfig.json in order to create .map files alongside the .js
files in the dist folder.
To use a debugger on TypeScript code, check your editor’s documentation, or use the debugger
keyword together with Node.js:
42
node inspect dist/index.js
{
"root": true,
"ignorePatterns": ["node_modules", "dist"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
]
}
npx eslint .
greet("TypeScript");
The linter will warn you about using let where const would be more appropriate:
43
Once let is replaced by const, the message won’t be shown again when linting the code.
The rightmost information of the error message (prefer-const) ist the name of the rule, which
can be disabled by adding in to the rules section of the eslintrc file:
{
…
"rules": {
"prefer-const": 0
}
}
When writing TypeScript, both the test and the production code are compiled to JavaScript. It’s
the duty of the TypeScript compiler to verify the use of TypeScript features; the unit tests only
verify JavaScript code.
Install the Jest test framework as follows:
The ts-jest package automatically compiles TypeScript files before the tests are executed. The
@types/jest package contains TypeScript definitions for the Jest API.
Create a test concifugartion file jest.config.js in the project’s root folder with the following con-
tent:
module.exports = {
roots: ["src"],
transform: { "^.+\\.tsx?$": "ts-jest" },
};
The source code will be looked up in the src folder. Files with the .ts and .tsx extension should
be processed by ts-jest.
For a file called foo.ts, a unit test is defined in a file called foo.test.ts.
Create a new file src/rounding.ts with the following code to be tested:
44
export function roundTo(value: number, granularity: number): number {
const factor = 1.0 / granularity;
const scaledUp = value * factor;
const rounded = Math.round(scaledUp);
const scaledDown = rounded / factor;
return scaledDown;
}
The test function provided by the Jest framework expects both a test description as a string and
a function performing the actual test. The expect function expects a function result, which than
can be further processed by a matcher function such as toBe.
The import statement does not require a .js extension, because internally CommonJS is used as the
module system.
The tests can be run as follows:
npx jest
This allows the tests to be quickly executed again (e.g. by pressing f to run failed tests, or by press-
ing [Enter] to run all tests again).
A complete list of Jest matcher functions can be found in the Jest documentation.
45
7 Understanding Static Types
The following code examples can be run in a project called types, similar to the tools project from
the above section.
In JavaScript, variables have no types, but values do. JavaScript’s typeof keyword returns the type
of an expression’s resulting value:
console.log(`${typeof "hello"}`);
console.log(`${typeof 12.5}`);
console.log(`${typeof true}`);
console.log(`${typeof { some: "thing" }}`);
Output:
string
number
boolean
object
To restrict variables, function parameters, and function return values to certain types, TypeScript
supports type annotations:
console.log(discount(50, 2.5));
The TypeScript compiler is able to infer the types of expressions. In this example, the result is
converted to a string:
46
The toFixed method turns the number into a string, which is returned from the discount function.
Since no variable nor return types have been annotated, the compiler has to figure out the types.
Extend the tsconfig.json file by adding the declaration setting:
{
"compilerOptions": {
…
"declaration": true
}
}
This will create a file called index.d.ts alongside index.js in the dist folder:
{
"compilerOptions": {
…
"noImplicitAny": true
}
}
If different types are acceptable for a variable or function argument, the allowed types can be nar-
rowed down using a type union rather than just allowing any type.
A type union is a list of types, separated by a bar, e.g. string | number to allow both strings and
numeric values.
The discount example from before is extended using a flag indicating whether or not the result
shall be formatted as a currency string, either returning a string or a number.
function discount(
amount: number,
percentage: number,
format: boolean,
47
): string | number {
const factor: number = (100 - percentage) / 100.0;
const discounted: number = amount * factor;
if (format) {
return `$${discounted.toFixed(2)}`;
}
return discounted;
}
The operations allowed on the discountedPrice variable are the intersection of operations allowed
on the string and on the number type, i.e. only toString() is allowed.
If the programmer knows more than the compiler, a type assertion using the as keyword can be used
to make sure TypeScript treats a variable as having a specific type. Using the discount function
from above:
Notice that no conversion is performed by using the as keyword; instead, TypeScript uses this
information to determine which operations are allowed on the variables.
A different approach is to use type guards using the typeof JavaScript keyword:
The TypeScript compiler figures out that in the second branch, discountedPrice is a number, there-
fore permitting the usage of its toFixed method.
The never type can be used to ensure that type guards are being used exhaustively, i.e. that no type
remains unhandled:
48
console.log(`$${discountedPrice.toFixed(2)}`);
} else {
let impossible: never = discountedPrice;
console.log(`unexpected type for value ${impossible}`);
}
If the discount function were to be declared with the type string | number | object, the assign-
ment of discountedPrice to impossible would actually happen, causing an error.
Unlike any, the type unknown can only be assigned to another type together with a type assertion.
The following code using any compiles:
When using unknown instead of any instead, this code would fail:
Error:
Since the values null and undefined are legal values for all types, the TypeScript compiler won’t
complain when using them:
function discount(
amount: number,
percentage: number,
format: boolean,
): string | number {
if (amount == 0.0) {
return null;
}
const factor: number = (100 - percentage) / 100.0;
const discounted: number = amount * factor;
if (format) {
49
return `$${discounted.toFixed(2)}`;
}
return discounted;
}
{
"compilerOptions": {
…
"strictNullChecks": true
}
}
The null type has to be included in the type union so that the program compiles and runs again:
function discount(
amount: number,
percentage: number,
format: boolean,
): string | number | null {
if (amount == 0.0) {
return null;
}
const factor: number = (100 - percentage) / 100.0;
const discounted: number = amount * factor;
if (format) {
return `$${discounted.toFixed(2)}`;
}
return discounted;
}
Note that typeof null returns "object", so the variable discountedPrice in the example above
needs to be compared to the value null; a type guard won’t help.
50
If the value null cannot be returned, i.e. by passing an amount argument not equal to 0.0 in the
example above—the programmer knows more than the compiler—a non-null assertion can be ap-
plied, which is a ! character after the expression producing a non-null value:
Note that the type null is still part of the function’s type union, but no longer of the variable’s.
The also uses the ! character, but right after the variable’s name.
If the compiler cannot detect the assignment of a value to a variable, but the programmer is sure
there is a value assigned, the definitive assignment assertion (also using the ! character, but right
after the variable’s name) can be used to prevent compilation errors.
This code fails to compile:
Error:
The TypeScript compiler cannot peek inside the eval function, but the programmer can:
8 Using Functions
Unlike many other languages, JavaScript does not support function overloading. In JavaScript, the
last function defined with a name will be called. TypeScript, being more strict, throws an error:
51
Output:
1. Implement a single function that accepts both kinds of parameters. (Here: a percentage and
an absolute value to be discounted.)
2. Implement two functions with distinct names. (Here: a discountPercentage and a dis-
countAbsolute function.)
The first approach makes the code harder to read, whereas the second approach adds clarity by
introducing more precise names.
In JavaScript, if too few arguments are passed to a function, the remainder parameters have the
value undefined. If too many arguments are passed, they are collected in the arguments array.
TypeScript, however, is stricter than JavaScript and does not allow a function to be called with a
different number of arguments.
Output:
The noUnusedParameters compiler option can be activated to produce an error if a function expects
parameters that aren’t actually used:
{
"compilerOptions": {
…
"noUnusedParameters": true
}
}
52
In this example, the absolute parameter is expected, but never used:
Output:
src/index.ts(1,55): error TS6133: 'absolute' is declared but its value is never read.
Optional parameters must be declared after the required parameters with the suffix ?:
function discount(
amount: number,
percentage: number,
absolute?: number,
): number {
const factor = (100 - percentage) / 100.0;
if (absolute) {
amount -= absolute;
}
return amount * factor;
}
console.log(discount(100, 5));
console.log(discount(100, 5, 10));
Output:
95
85.5
To avoid explicit checks whether or not an optional argument has been provided, a fallback value
can be used:
function discount(
amount: number,
percentage: number,
absolute?: number,
): number {
const factor = (100 - percentage) / 100.0;
53
return (amount - (absolute || 0)) * factor;
}
However, since a fallback value can be used, it can be provided using a default-initialized parame-
ter, being declared without a question mark:
function discount(
amount: number,
percentage: number,
absolute: number = 0,
): number {
const factor = (100 - percentage) / 100.0;
return (amount - absolute) * factor;
}
console.log(discount(100, 5));
console.log(discount(100, 5, 10));
Even though the parameter absolute was declared without a ?, it sill is an optional parameter and
therefore needs to be listed after the required parameters.
A variable number of arguments can be provided using a rest parameter, declared using an ellipsis
(...), which collects zero, one, or multiple arguments provided after the optional parameters:
function discount(
amount: number,
percentage: number,
absolute: number = 0,
...fees: number[]
): number {
const factor = (100 - percentage) / 100.0;
const totalFees = fees.reduce((acc, e) => acc + e, 0);
return (amount - absolute + totalFees) * factor;
}
console.log(discount(100, 5));
console.log(discount(100, 5, 10));
console.log(discount(100, 5, 10, 1));
console.log(discount(100, 5, 10, 1, 2));
console.log(discount(100, 5, 10, 1, 2, 3));
Output:
95
85.5
54
86.45
88.35
91.19999999999999
If a function expecting a default-initialized parameter is called with the argument null, its default
value is used instead, as if the function had be called without passing an argument for this default-
initialized parameter.
The strictNullChecks compiler option disables the usage of null and undefined without declaring
the parameters using an according type union, e.g. number | null. The possibility of null being
passed then needs to be accounted for programmatically:
function discount(
amount: number,
percentage: number,
absolute: number | null = 0,
): number {
const factor = (100 - percentage) / 100.0;
return (amount - absolute) * factor;
}
console.log(discount(100, 5, null));
Output:
function discount(
amount: number,
percentage: number,
absolute: number | null = 0,
): number {
const factor = (100 - percentage) / 100.0;
if (absolute === null) {
absolute = 0;
}
return (amount - absolute | 0) * factor;
}
console.log(discount(100, 5, null));
55
If no return type is annotated for a function that returns values of different types, the TypeScript
compiler will automatically infer a type union for the function’s return type. If the declaration
setting is enabled, the inferred type unions can be seen in the according declarations file, e.g. in
dist/index.d.ts for the code in src/index.ts. The discount function can either return a number or
a string, depending on the value of the format parameter:
declare function discount(amount: number, percentage: number, format: boolean): string | number;
A JavaScript function that never explicitly returns a value using the return keyword will return
undefined implicitly when called. Implicit returns can be prevented by enabling the noImplic-
itReturns compiler option in tsconfig.json:
{
"compilerOptions": {
…
"noImplicitReturns": true
}
}
When activated, each execution path of a function must return a value explicitly, otherwise the
compiler throws an error:
Output:
error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
Once an explicit return is introduced for the case that whom is the empty string, the function will
be compiled:
56
function greet(whom: string): string {
if (whom !== "") {
return `Hello, ${whom}!`;
}
return "";
}
A function’s return type can depend on the type of its parameters. However, this relationship
remains implicit and requires reading the code to be uncovered, as in the following function:
console.log(multiply(3, 5));
console.log(multiply("oh", 5));
Output:
15
oh oh oh oh oh
A type overload describes such a relationship using different function declarations, expressing valid
type combinations:
57
let product: number = multiply(3, 5);
let output: string = multiply("oh", 5);
console.log(product);
console.log(output);
When the function multiply is called with an argument of type number for the parameter x, the
compiler infers that the resulting value must be of type number, too; no type assertion is needed
when assigning the return value to a variable of type number.
The asserts keyword indicates that a function performs a type assertion on a parameter value,
which either passes or throws an exception. Consider the following function:
Assertions can also be used for specific types, e.g. to make sure that a parameter is actually nu-
meric:
58
}
}
TypeScript allows to restrict the types of the elements an array can cake up using type annota-
tions:
Written as number | string[] (i.e. without parentheses), the type union would denote the type to
be either a single number or an array of strings; hence the parentheses around the type union.
Note: Such a type union means that each element of an array can be either a number or a string, and
not that all elements have to bei either numbers or strings. Therefore, values of different types can
be mixed in the array.
The TypeScript compiler infers type unions for arrays based on the values being used upon initial-
ization:
When the declaration compiler option is activated, the following declarations are produced
(dist/index.d.ts):
Declarations:
59
9.1 Tuples
Tuples are fixed-length arrays, in which every element can have a different type. Under the hood,
tuples are implemented as regular JavaScript arrays:
A type annotation is always required for tuples, otherwise they would be treated as regular ar-
rays. Tuples can be used together with JavaScript’s array features, such as accessing individual
elements using square brackets and an index.
Optional elements (following non-optional elements) are supported using the ? suffix, turning
the type of such al element to a type union with undefined, e.g. number? becomes number | unde-
fined.
Tuples can also use rest elements, making the number of elements more flexible—and thereby
defeating the point of tuples.
Consider the following example in which an optional element denotes the best friend, and the
rest parameters denote more friends:
9.2 Enums
Enums are groups of fixed values that can be accessed by a name. An enum can be declared using
the enum keyword:
60
enum Position {
Engineer,
Manager,
Trainee,
}
enum Position {
Engineer = 1,
Manager = 2,
Trainee = 3,
}
Output:
Engineer
Enums are implemented by the TypeScript compiler, which automatically assigns a number value
to every name, as shown in the declarations file:
61
declare enum Position {
Engineer = 0,
Manager = 1,
Trainee = 2,
}
declare let employees: [string, Position][];
The numbers can be assigned manually. It’s a good practice to either assign numbers manually
to either all or none of the names to avoid duplications. The first enum Position defines numbers
for all names, the second enum State defines numbers only for two names:
enum Position {
Engineer = 1,
Manager = 2,
Trainee = 3,
}
enum State {
Waiting,
Running = 3,
Sleeping,
Starting,
Stopping = 4,
}
The names with missing values are enumerated automatically, which fails in the second case, as
the declaration shows:
Both State.Sleeping and State.Stopping have the value 4, which creates a conflict:
console.log(State.Sleeping == State.Stopping);
Output:
62
true
enum Country {
Switzerland = "CH",
Germany = "DE",
France = "FR",
}
console.log(Country.Switzerland);
Output:
CH
Two distinct enums cannot be compared to one another, even if they share the underlying val-
ues:
enum Institution {
Insurance,
Bank,
Court,
}
enum Furniture {
Chair,
Bank,
Table,
}
console.log(Institution.Bank == Furniture.Bank);
Output:
error TS2367: This comparison appears to be unintentional because the types 'Institution' and 'Furniture' have n
Enums are implemented as JavaScript objects, unless they are declared using the const keyword,
in which case all the references to an enum are inlined. However, const enums cannot be accessed
using their labels:
63
const enum State { Running, Sleeping, Waiting, Starting, Stopping }
Output:
error TS2476: A const enum member can only be accessed using a string literal.
A literal value type defines a set of values that can be used in a certain place, e.g. to be assigned to
a variable. Syntactically similar to type unions, literal values instead of types are used:
The initial and the two subsequent assignments are valid, but the third is not:
error TS2322: Type '"black"' is not assignable to type '"red" | "yellow" | "green"'.
Literal value types are most useful when applied to function parameters:
It’s possible to assign overlapping values to variables that are restricted by different sets of val-
ues:
let twos: 2 | 4 | 6 | 8 = 4;
let threes: 3 | 6 | 9 | 12 = 9;
twos = 6;
threes = 6;
twos = 3;
The first two reassignments are valid, but the third is not:
64
error TS2322: Type '3' is not assignable to type '2 | 4 | 6 | 8'.
Literal value types can be mixed with actual types in type unions:
It’s also possible to use literal value types with string templates:
function greet(
name: "Alice" | "Bob" | "Mallory",
): `Hello, ${"Alice" | "Bob" | "Mallory"}` {
return `Hello, ${name}`;
}
Type definitions are often used at multiple places, making it tedious to use and especially change
them:
65
Using a type alias, the type can have a name assigned, which then can be re-used:
The object’s shape is the combination of its property names and types. With the declarations
compiler option activated, the inferred shapes of the used objects can be seen in the declarations
file.
In the following example, all three objects share the name and age property:
TypeScript infers that the name and age properties are available in all three objects, and therefore
the first console.log statement is valid. However, the second isn’t, because it tries to access the
role property, which is only defined on two of the three objects:
Optional properties can be defined with the ? suffix. In order to make use of optional properties,
additional type guards using in have to be used to ensure that an optional property is actually
available. For optional methods, it has to be checked that they are not undefined before being
called:
type employee = {
name: string;
age: number;
salary: number;
role?: string;
66
lazy?: boolean;
calculateBonus?(salary: number): number;
};
67
In the above example, both role and lazy are optional properties. Their access is surrounded by
in checks. The optional method calculateBonus is checked to be defined before being invoked,
otherwise the compiler would throw an error.
When using a union of shape types, the properties common to both shape types can be used with-
out any further checks:
type Employee = {
id: number;
name: string;
salary: number;
};
type Product = {
id: number;
name: string;
price: number;
};
In order to make use of other properties, they can either be checked for availability using the in
keyword—or by applying a type predicate to it, which are functions that assert that an expression
is of a certain type:
type Employee = {
id: number;
name: string;
salary: number;
};
type Product = {
id: number;
name: string;
price: number;
};
68
let dilbert: Employee = { id: 745, name: "Dilbert", salary: 100000 };
let alice: Employee = { id: 931, name: "Alice", salary: 90000 };
let stapler: Product = { id: 4529, name: "stapler", price: 8.35 };
let chair: Product = { id: 7826, name: "chair", price: 249.99 };
A type intersection combines two types to a new type that is only satisfied by values that conform
to both original types. For object shapes, the intersection of two shapes requires that a value pro-
vides all the properties defined by either shape:
type Product = {
id: number;
name: string;
price: number;
};
type Stock = {
id: number;
quantity: number;
};
69
let stock: StockedProduct[] = [
{ id: 1, name: "Stapler", price: 8.90, quantity: 17 },
{ id: 2, name: "Chair", price: 215.99, quantity: 3 },
{ id: 3, name: "Lamp", price: 89.95, quantity: 7 },
];
stock.forEach((i) => {
const value: number = i.price * i.quantity;
console.log(`${i.id}) ${i.name}:\t${value.toFixed(2)}`);
});
Output:
1) Stapler: 151.30
2) Chair: 647.97
3) Lamp: 629.65
The types Product and Stock have the id property in common; the name and price property of Prod-
uct as well as the quantity property of Stock are only defined by one of the types.
To satisfy the StockedProduct type, which is an intersection of Product and Stock, objects need to
define all four properties, which then can be safely used.
When multiple types of an intersection define the same property name, the intersected type uses
an intersection of their types. If the types are identical, this particular type is used. If different
types are involved, the intersection of that type is used. If no useful intersection can be found, e.g.
because number and string have nothing in common, the never type is inferred, which makes it
impossible to use the type. For methods, it is a good practice to consult the declarations file to
figure out what implementation in terms of types has to be provided.
In JavaScript, constructor functions can be used to create objects consistently. Unfortunately, con-
structor functions are treated in TypeScript like any other function, and the compiler cannot infer
a type other than any:
70
return `${this.id}: ${this.name}`;
};
let employees = [
new Employee(1, "Dilbert"),
new Employee(2, "Alice"),
new Employee(3, "Wally"),
];
Output:
'new' expression, whose target lacks a construct signature, implicitly has an 'any' type.
TypeScript neglects support for constructor functions in favor of classes, with which the Employee
type from above can be expressed as follows:
class Employee {
id: number;
name: string;
describe(): string {
return `${this.id}: ${this.name}`;
}
}
The objects in the employees array now have a proper type, which allows them to be tested using
the instanceof keyword.
In addition to JavaScripts # operator for private properties, TypeScript supports the access con-
trol keywords public (default), private, and protected. However, those are only enforced during
compilation, but not in the emitted JavaScript code.
71
Properties defined as readonly can only be assigned in the constructor and only read afterwards.
Inheritance is implemented using the extends keyword. A subclass needs to invoke the construc-
tor of its superclass using the super keyword.
The following example shows how those concepts are being used together:
class Person {
private readonly id: number;
public name: string;
protected active: boolean;
identify(): number {
return this.id;
}
}
constructor(
id: number,
name: string,
active: boolean,
segment: "b2b" | "b2c",
) {
super(id, name, active);
this.revenue = 0;
this.segment = segment;
72
}
book(revenue: number) {
if (this.active) {
this.revenue += revenue;
} else {
throw new Error("Cannot book revenue on inactive customer.");
}
}
}
let people = [
new Person(1, "Patrick", true),
new Employee(2, "John", true, "Engineer"),
new Customer(3, "Jill", true, "b2c"),
];
TypeScript extends the constructor syntax by allowing visibility keywords for constructor param-
eters. Those parameters are then automatically turned into properties, and their values will be
assigned to those properties.
The example from above can be simplified as follows using extended constructor syntax:
class Person {
constructor(
private readonly id: number,
public name: string,
protected active: boolean,
) {}
identify(): number {
return this.id;
}
}
73
active: boolean,
public position: string,
) {
super(id, name, active);
}
}
constructor(
id: number,
name: string,
active: boolean,
public segment: "b2b" | "b2c",
) {
super(id, name, active);
this.revenue = 0;
}
book(revenue: number) {
if (this.active) {
this.revenue += revenue;
} else {
throw new Error("Cannot book revenue on inactive customer.");
}
}
}
let people = [
new Person(1, "Patrick", true),
new Employee(2, "John", true, "Engineer"),
new Customer(3, "Jill", true, "b2c"),
];
Notice that the constructor body remains empty for the class Person. The class Employee, however,
still calls the constructor if its superclass explicitly. Since the revenue property of the Customer
class cannot be passed to the constructor, the property remains explicitly defined and assigned.
Getter and setter methods can be defined using the get and set keyword. If only a get method
is defined, the property becomes a read-only property. Usually, getter and setter methods have a
backing field which stores the value being accessed. However, this is optional, and the value can
be stored in a different way for a setter, or be calculated for a getter method.
The accessor keyword defines a property with an optional initial value, for which getter and setter
74
methods are automatically generated.
The following example demonstrates the usage of those features:
class Person {
private jobs: string[] = new Array();
constructor(
private id: number,
private firstName: string,
private lastName: string,
) {}
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
A class defined with the abstract keyword cannot be instantiated, but extended by other classes.
Any method declared as abstract needs to be implemented by the subclass.
75
The concrete methods of an abstract class can call abstract methods, which need to be imple-
mented by the subclasses so that the method call can be resolved.
The following example defines an abstract class Animal, which delegates species-specific opera-
tions to the concrete subclassess:
describe(): string {
return `I'm ${this.name} the ${this.species()} and I make «${this.noise()}».`;
}
species(): string {
return "Horse";
}
noise(): string {
return "Neigh";
}
}
species(): string {
return "Cat";
}
noise(): string {
return "Meow";
}
}
76
let animals = [new Horse("Betty"), new Cat("Dimka")];
animals.forEach((a) => console.log(a.describe()));
Output:
Notice that in the above example the TypeScript compiler infers the type union Horse | Cat for the
animals array. Annotating animals with the type Animal[] makes sense here, because the compiler
doesn’t infer that only the API common to those two classes is used (via its abstract superclass).
11.1.2 Interfaces
The shapes of objects that are based on a class can be described using interfaces. They are very sim-
ilar to shape types, but declared using the interface keyword. Interfaces can both define proper-
ties and methods.
A class uses the implements keyword to declare that it provides all the properties and methods an
interface declares. Unlike inheritance, which only allows for one extends declaration, implements
can list multiple interfaces.
Multiple interface declarations within the same file are merged into one interface.
The following example defines a hierarchy of classes and interfaces:
interface Shape {
circumference(): number;
area(): number;
}
interface Named {
name: string;
}
interface Describable {
color?: string;
describe(): string;
}
77
abstract circumference(): number;
abstract area(): number;
}
circumference(): number {
return 4 * this.side;
}
area(): number {
return this.side * this.side;
}
}
circumference(): number {
return 2 * this.side + 2 * this.otherSide;
}
area(): number {
return this.side * this.otherSide;
}
describe(): string {
return `${this.name} of ${this.side}x${this.otherSide}`;
}
}
78
new Square(5),
];
shapes.forEach((s) => {
let description: string;
if ("describe" in s) {
description = (s as Describable).describe();
} else if ("name" in s && s.name) {
description = (s as Named).name;
} else {
description = "Unknown shape";
}
console.log(
`${description} with circumference of ${s.circumference()} and area of ${s.area()}`,
);
});
• The Shape interface defines two methods: circumference and area, which both return a num-
ber.
• The Named interface defines a property: name of type string.
• The Describable interface defines an optional property color and a method describe; both
of type string.
• The abstract class Rectangular implements both Shape and Named interface. It needs to im-
plement the two methods of Shape, but only provides abstract methods, which must then
be implemented by a concrete class.
• The Square class extends the abstract class Rectangular and therefore needs to implement
both circumference and area.
• The Rectangular class extends Square and implements another interface: Describable. It,
therefore, has to implement three methods: circumference, area, and describe.
• The shapes array (type Shape[]) uses the most common denominator of the three instances
stored as its type.
Notice that the instanceof operator is of no use when testing for interfaces, because interfaces
only exist up to compile time, but not in the JavaScript code actually executed. Therefore, type
checks for interfaces have to be implemented in a clumsier way.
Output:
79
11.1.3 Dynamic Properties
JavaScript allows for properties to be used dynamically, whereas TypeScript restricts properties to
those defined explicitly. An index signature allows for dynamic properties within TypeScript code
with restricted types.
The property names can be of type string or number, but the property value can be of any type.
An index signature is defined as follows, e.g. with string property names and number property
values:
class Product {
dimensions: Dimensions;
constructor(
public name: string,
public inStock: boolean,
) {
this.dimensions = new Dimensions();
}
class Dimensions {
[propertyName: string]: number;
}
80
To prevent access to undefined properties, activate the noUncheckedIndexedAccess compiler op-
tion.
Generics allow the use of type parameters in place of specific types. The specific type is filled in
upon usage, allowing existing code to work with types that do not exist yet.
The Item class allows different types being used for its id property (item.ts):
describe(): string {
return `${this.id}: ${this.name}`;
}
}
The generic type parameter is written within angle brackets, and its naming conventionally starts
with T.
Unlike the name property, which uses the specific type string, the id property can use any type T,
which has to be filled in with a specific type when the class Item is being used:
class Coordinates {
constructor(
public latitude: number,
public longitude: number,
) {}
toString(): string {
return `${this.latitude};${this.longitude}`;
}
}
81
"Chorweiler",
);
console.log(dilbert.describe());
console.log(stapler.describe());
console.log(chorweiler.describe());
Output:
317: Dilbert
a3-v5-x7: Stapler
51.028679;6.89476: Chorweiler
The Item class not only supports number, string, or Coordinates as types for the id property, but any
type that can be used with string interpolation, as used in its describe method (e.g. by providing
a toString method).
The following example demonstrates the use of generics using geometry shapes (shapes.ts):
circumference(): number {
return 2 * this.width + 2 * this.height;
}
area(): number {
return this.width * this.height;
}
}
circumference(): number {
return 4 * this.side;
}
82
area(): number {
return this.side * this.side;
}
}
circumference(): number {
return 2 * this.radius * Math.PI;
}
area(): number {
return this.radius * this.radius * Math.PI;
}
}
volume(): number {
return this.base.area() * this.height;
}
}
The Rectangle, Square, and Circle class are unrelated, even though they implement the same
methods (circumference and area), which could be grouped together by an interface. The class
Body expects a generic type parameter T, which matches for any type that extends (or: implements,
for that matter) the Shape interface. With an additional width, a shape is turned into a body:
Thanks to the type restriction on T (it must implement Shape), the area method can be used on the
base object, without narrowing its type down to one specific type.
83
import { Body, Circle, Rectangle, Square } from "./shapes.js";
console.log(
`rectangle circumference: ${rectangle.circumference()};`,
`rectangle area: ${rectangle.area()}`,
);
console.log(
`square circumference: ${square.circumference()};`,
`square area: ${square.area()}`,
);
console.log(
`circle circumference: ${circle.circumference().toFixed(2)};`,
`circle area: ${circle.area().toFixed(2)}`,
);
Output:
Generic types can also be restricted using a shape, so that only objects with certain properties or
methods can be used. This is more flexible than restricting the type using a type union, which
needs to be extended each time a new (compabible) type is additionally used.
The following example restricts its type parameter T using a shape type:
84
constructor() {
this.items = new Array();
}
add(item: T) {
this.items.push(item);
}
getNames(): string[] {
return this.items.map((i) => i.name);
}
}
class Dog {
constructor(
public name: string,
public race: string,
) {}
}
class Company {
constructor(
public name: string,
public revenue: number,
) {}
}
Output:
A class can make use of multiple type parameters, listed within angle brackets, separated by com-
mas, e.g. <T, U>. The following class computes the volume based on an object that has an area,
85
and on another one that has a height:
volume(): number {
return this.withArea.area() * this.withHeight.height;
}
}
const rect = {
a: 3,
b: 4,
area: function (): number {
return this.a * this.b;
},
};
const height = {
height: 5,
};
const conv = new ShapeConverter(rect, height);
console.log(conv.volume());
Output:
60
The second type parameter U could also be moved to the volume method, making it generic:
86
}
const rect = {
a: 3,
b: 4,
area: function (): number {
return this.a * this.b;
},
};
const height = {
height: 5,
};
const conv = new ShapeConverter(rect);
console.log(conv.volume(height));
Output:
60
Notice that the type parameters haven’t been stated explicitly, but have been inferred by the com-
piler. Check the declarations file to find out which types have been inferred.
12.3 Inheritance
When extending a class that expects a type parameter, the subclass has to provide a compatible
type to its superclass:
constructor(
private name: string,
thing: T,
) {
this.id = thing.identify();
}
}
class StockedItem<
T extends { identify(): string; count(): number },
> extends Item<T> {
private stock: number;
87
constructor(name: string, thing: T) {
super(name, thing);
this.stock = thing.count();
}
}
A subclass can also fix the generic type of its superclass by replacing the type parameter with a
specific type. A type parameter can also be further restricted in a subclass using a type union,
as long as the type parameter in the subclass is more restrictive than the one of its superclass.
(Values of the type parameter of the subclass must be assignable to the type parameter of the
superclass.)
The instanceof operator cannot be used for checking generic type arguments, because the type
information is only available during compilation time, but not during run time. A predicate func-
tion can be used instead.
The following code won’t compile:
class Collection<T> {
private items: T[] = [];
add(item: T) {
this.items.push(item);
}
class Car {
constructor(
public brand: string,
public model: string,
) {}
}
class Drink {
constructor(
88
public brand: string,
public name: string,
) {}
}
Error:
error TS2693: 'V' only refers to a type, but is being used as a value here.
However, the problem can be fixed using a predicate function, which asserts the type at compila-
tion time:
class Collection<T> {
private items: T[] = [];
add(item: T) {
this.items.push(item);
}
class Car {
constructor(
public brand: string,
public model: string,
) {}
}
class Drink {
constructor(
public brand: string,
public name: string,
) {}
}
89
function isDrink(target: any): target is Drink {
return target instanceof Drink;
}
Static methods can accept their own type arguments, just as instance methods.
Generics can also be used for interfaces, which can further restrict generic types as subclasses can
do.
JavaScript’s collections can be used with generic type parametes, e.g. Map<K, V> with type K for
keys and type V for values, and Set<T> with type T for the (unique) values:
console.log(uniqueNumbers);
console.log(numbersCount);
90
The following example shows how the Iterator and IteratorResult types are used explicitly:
Notice that iterators are only available since ES6. To target ES5 an earlier, the downlevelIteration
compiler option needs to be set to true.
The prices iterator in the above example cannot directly be used in a for/of loop:
error TS2488: Type 'Iterator<number, any, any>' must have a '[Symbol.iterator]()' method that returns an iterat
This is where the IterableIterator<T> interfaces is useful, which allows for objects to be directly
iterated over by combining the Iterator<T> with the Iterable<T> interfaces:
class Menu {
private menu: Map<string, number>;
constructor() {
this.menu = new Map<string, number>();
}
prices(): IterableIterator<number> {
return this.menu.values();
}
91
}
The method Map.values returns an iterator, which can be accessed over the Menu.prices method.
To go one step further, the Menu class can be made iterable, saving the explicit method call:
constructor() {
this.menu = new Map<string, number>();
}
[Symbol.iterator](): Iterator<number> {
return this.menu.values();
}
}
92
13.1 Index Types
Given a type T, the index type query operator keyof returns a union of the type’s property names,
which can be used as a type. This allows to constrain the dynamic access to properties:
class Accessor<T> {
constructor(private object: T) {}
class Person {
constructor(
public name: string,
public age: number,
) {}
}
Output:
accessor.set("dead", true);
error TS2345: Argument of type '"dead"' is not assignable to parameter of type 'keyof Person'.
The compiler detects that there is no such property dead on the type Person and refuses to compile
the code.
The indexed access operator can be used together with the type keyword:
93
class Person {
constructor(
public name: string,
public age: number,
) {}
}
Output:
Dilbert 42
A type defined using the indexed access operator is known as a lookup type. Such types are most
useful in conjunction with generic types, whose properties cannot be known beforehand.
Index types can be used to map types, i.e. to programmatically create new types of existing types,
thereby retaining or changing the original type’s properties.
The following examples maps an existing type Product using a generic type mapping:
class Product {
constructor(
public id: number,
public name: string,
public stock: number,
) {}
}
type Mapped<T> = {
[P in keyof T]: T[P];
};
94
This feature becomes useful when it is used to change properties to change their access mode
(optional/required) or readonly mode:
class Product {
constructor(
public id: number,
public name: string,
public stock: number,
) {}
}
type Mapped<T> = {
[P in keyof T]: T[P];
};
type MappedOptional<T> = {
[P in keyof T]?: T[P];
};
type MappedRequired<T> = {
[P in keyof T]-?: T[P];
};
type MappedReadonly<T> = {
readonly [P in keyof T]: T[P];
};
type MappedReadWrite<T> = {
-readonly [P in keyof T]: T[P];
};
95
let p: ReadWriteProduct = { id: 3, name: "Beer", stock: 17 };
p.name = `Lager ${p.name}`;
p.stock--;
console.log(p);
A conditional type is a placeholder for a result type to be chosen as a generic type argument is used:
The type parsedType can be used with either true or false. In the first case, the type becomes
number, in the second case, the type becomes string. The first two variables (parsed, unparsed) are
correct assignments. The third variable (mismatch) causes an error:
Using nested conditional types increases the risk of missing some possible combinations of valid
types/values, which makes the code harder to understand, and creates holes in the type system.
Conditional types can be nested:
96
let unparsed: inputState<"raw"> = "123";
let parsed: inputState<"parsed"> = 123;
14 Using Decorators
Decorators are functions that transform classes, methods, fields, and accessors by replacing with-
out otherwise modifying them. TypeScript 5 already supports decorators, and a future JavaScript
specification will adopt them natively.
Decorators are applied using the @ character in front of their name. The following decorator logs
the time method invocations take:
class Factorial {
constructor(private n: number) {}
@timed
calculate(): number {
return this.doCalculate(this.n);
}
97
doCalculate(i: number): number {
if (i == 0) {
return 1;
} else {
return i * this.doCalculate(i - 1);
}
}
}
console.log(new Factorial(10).calculate());
• kind: the kind of element the decorator has been applied to (e.g. "method")
• name: the name of the element the decorator has been applied to (string or symbol)
• static: whether or not the decorator has been applied to a static element
• private: whether or not the decorator has been applied to a private element
The performance API is used for timing purposes. Notice that the method is replaced by a function
that performs the original task (i.e. calls the original method in its context) plus some additional
task (measuring its timing).
For other kinds of decorators, other context types are used:
• Class: ClassDecoratorContext
• Methods: ClassMethodDecoratorContext
• Fields: ClassFieldDecoratorContext
• Accessors: ClassGetterDecoratorContext and ClassSetterDecoratorContext
• Auto-accessors: ClassAccessorDecoratorContext
98