Exploring Ts Screen Preview
Exploring Ts Screen Preview
2025-04-21
Copyright © 2025-04-21 by Dr. Axel Rauschmayer
All rights reserved. This book or any portion thereof may not be reproduced or used in
any manner whatsoever without the express written permission of the publisher except
for the use of brief quotations in a book review or scholarly journal.
exploringjs.com
Table of contents
I Preliminaries 9
1 About this book 11
1.1 Where is the homepage of this book? . . . . . . . . . . . . . . . . . . . . . 11
1.2 What is in this book? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
1.3 What do I get for my money? . . . . . . . . . . . . . . . . . . . . . . . . . 12
1.4 How can I preview the content? . . . . . . . . . . . . . . . . . . . . . . . 12
1.5 How do I report errors? . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
1.6 What do the notes with icons mean? . . . . . . . . . . . . . . . . . . . . . 12
3
4
8 Guide to tsconfig.json 73
8.1 Features not covered by this chapter . . . . . . . . . . . . . . . . . . . . . 74
8.2 Extending base files via extends . . . . . . . . . . . . . . . . . . . . . . . 75
8.3 Where are the input files? . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
8.4 What is the output? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
8.5 Language and platform features . . . . . . . . . . . . . . . . . . . . . . . 79
8.6 Module system . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
8.7 Type checking . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
8.8 Compiling TypeScript with tools other than tsc . . . . . . . . . . . . . . . 87
5
15.4 Use case for never: exhaustiveness checks at compile time . . . . . . . . . 129
15.5 Use case for never: forbidding properties . . . . . . . . . . . . . . . . . . 131
15.6 Functions that return never . . . . . . . . . . . . . . . . . . . . . . . . . . 131
15.7 Sources of this chapter . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132
Preliminaries
9
Chapter 1
11
12 1 About this book
Required knowledge: You must know JavaScript. If you want to refresh your knowledge:
My book “Exploring JavaScript” is free to read online.
• PDF file
• ZIP archive with ad-free HTML
• EPUB file
Reading instructions
Explains how to best read the content.
External content
Points to additional, external, content.
Tip
Gives a tip related to the current content.
Question
Asks and answers a question pertinent to the current content (think FAQ).
1.6 What do the notes with icons mean? 13
Warning
Warns about pitfalls, etc.
Details
Provides additional details, complementing the current content. It is similar to a
footnote.
GitHub repository
Mentions a relevant GitHub repository.
14 1 About this book
Chapter 2
Roughly, TypeScript is JavaScript plus type information. The latter is removed before Type-
Script code is executed by JavaScript engines. Therefore, writing and deploying TypeScript
is more work. Is that added work worth it? In this chapter, I’m going to argue that yes, it
is. Read it if you are skeptical about TypeScript but interested in giving it a chance.
You can skip this chapter if you’re already sure you want to learn and
use TypeScript
15
16 2 Sales pitch for TypeScript
That makes it easier to automatically test all the source code in this chapter. It’s also a
built-in TypeScript feature that can be useful (albeit rarely).
• Line A: TypeScript knows the type of point1 and it doesn’t have a property .z.
2.2 TypeScript benefit: auto-completion and detecting more errors during editing 17
• Line B: point1.x is a number and therefore doesn’t have the string method .toUpperCase(
).
• Line C: This invocation works because the second argument of new Point() is op-
tional.
In line A, we get auto-completion after point1. (the properties x and y of that object):
function reverseString(str) {
if (str.length === 0) {
return str;
}
Array.from(str).reverse();
}
Let’s see what TypeScript tells us if we add type annotations (line A):
• At the end, there is no return statement – which is true: We forgot to start line B
with return and therefore implicitly return undefined after line B.
• The implicitly returned undefinedis incompatible with the return type string (line
A).
In line B, we are returning an Array while the return type in line A says that we want to
return a string. If we fix that issue too, TypeScript is finally happy with our code:
type NameDef = {
name?: string, // (A)
nick?: string, // (B)
};
In other words: NameDef objects have two properties whose values are strings. Both
properties are optional – which is indicated via the question marks in line A and line B.
The following code contains an error and TypeScript warns us about it:
?? is the nullish coalescing operator that returns its left-hand side – unless it is undefined
or null. In that case, it returns its right-hand side. For more information, see “Exploring
JavaScript”.
nameDef.name may be missing. In that case, the result is undefined and not a string. If we
fix that, TypeScript does not report any more errors:
In other words: a color is either the string 'red' or the string 'green' or the string 'blue'.
The following function translates such colors to CSS hexadecimal color values:
In line A, we get an error because we return a string that is incompatible with the return
type `#${string}`: It does not start with a hash symbol.
The error in line C means that we forgot a case (the value 'blue'). To understand the error
message, we must know that TypeScript continually adapts the type of color:
And that type is incompatible with the special type never that the parameter of new UnexpectedValueError(
) has. That type is used for variables at locations that we never reach. For more informa-
tion see “The bottom type never” (§15).
value: never,
// Avoid exception if `value` is:
// - object without prototype
// - symbol
message = `Unexpected value: ${{}.toString.call(value)}`
) {
super(message)
}
}
Lastly, TypeScript gives us auto-completion for the argument of getCssColor() (the values
'blue', 'green' and 'red' that we can use for it):
type Content =
| {
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
| {
kind: 'video',
width: number,
height: number,
runningTimeInSeconds: number,
}
;
TypeScript warns us because not all kinds of content have the property .content. How-
ever, they all do have the property .kind – which we can use to fix the error:
Note that TypeScript does not complain in line A, because we have excluded text content,
which is the only content that does not have the property .width.
That does not tell us much about the arguments expected by filter(). We also don’t know
what it returns. In contrast, this is what the corresponding TypeScript code looks like:
function filter(
items: Iterable<string>,
callback: (item: string, index: number) => boolean
): Iterable<string> {
// ···
}
Yes, the type notation takes getting used to. But, once we understand it, we can quickly
get a rough understand of what filter() does. More quickly than by reading prose in
English (which, admittedly, is still needed to fill in the gaps left by the type notation and
the name of the function).
I find it easier to understand TypeScript code bases than JavaScript code bases because, to
me, TypeScript provides an additional layer of documentation.
This additional documentation also helps when working in teams because it is clearer how
code is to be used and TypeScript often warns us if we are doing something wrong.
to check where it is invoked. That means that static types give me information locally that
I otherwise have to look up elsewhere.
• On server side JavaScript platforms such as Node.js, Deno and Bun, we can run
TypeScript directly – without compiling it.
• Most bundlers such as Vite have built-in support for TypeScript.
Alas, type checking is still relatively slow and must be performed via the TypeScript com-
piler tsc.
– For my own projects, I’m now using a maximally strict tsconfig.json – which
eliminated my doubts about what my tsconfig.json should look like.
– Type stripping (see previous section) has clarified the role of tsconfig.json
for me: With them, it only configures how type checking works. Generating
JavaScript can be done without tsconfig.json.
The only non-JavaScript syntax in this code is <T>: Its first occurrence setDifference<T>
means that the function setDifference() has a type parameter – a parameter at the type
level. All later occurrences of <T> refer to that parameter. They mean:
• The parameters set1 and set2 are Sets whose elements have the same type T.
• The result is also a Set. Its elements have the same type as those of set1 and set2.
Note that we normally don’t have to provide the type parameter <T> – TypeScript can
extract it automatically from the types of the parameters:
assert.deepEqual(
setDifference(new Set(['a', 'b']), new Set(['b'])),
new Set(['a']),
);
assert.deepEqual(
setDifference(new Set(['a', 'b']), new Set(['a', 'b'])),
new Set(),
);
When it comes to using setDifference(), the TypeScript code is not different from JavaScript
code in this case.
TypeScript 0.8 was released in October 2012 when JavaScript had remained stagnant for a
long time. Therefore, TypeScript added features that its team felt JavaScript was missing -
24 2 Sales pitch for TypeScript
Since then, JavaScript has gained many new features. TypeScript now tracks what JavaScript
provides and does not introduce new language-level features anymore – for example:
• In 2012, TypeScript had its own way of doing modules. Now it supports ECMA-
Script modules and CommonJS.
• In 2012, TypeScript had classes that were transpiled to functions. Since ECMAScript
6 came out in 2015, TypeScript has supported the built-in classes.
• In 2015, TypeScript introduced its own flavor of decorators, in order to support An-
gular. In 2022, ECMAScript decorators reached stage 3 and TypeScript has sup-
ported them since. For more information, see section “The history of decorators” in
the 2ality post on ECMAScript decorators.
• TypeScript will only get better enums or pattern matching if and when JavaScript
gets them.
type Content =
| {
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
| {
kind: 'video',
width: number,
height: number,
runningTimeInSeconds: number,
}
;
In Haskell, this data type would look like this (without labels, for simplicity’s sake):
data Content =
Text Int
2.7 TypeScript FAQ 25
One key insight for making sense of advanced types, is that they are mostly like a new
programming language at the type level and usually describe how input types are trans-
formed into output types. In many ways, they are similar to JavaScript. There are:
For more information on this topic, see “Overview: computing with types”.
Sometimes they are – for example, as an experiment, I wrote a simple SQL API that gives
you a lot of type completions and warnings during editing (if you make typos etc). Note
that writing that API involved some work; using it is simple.
“The basics of TypeScript” (§4) teaches you those basics. If you are new to TypeScript,
I’d love to hear from you: Is my assumption correct? Were you able to write (simple)
TypeScript after reading it?
26 2 Sales pitch for TypeScript
Chapter 3
• “TypeScript Deep Dive” by Basarat Ali Syed was not updated much after 2020 – e.g.,
it does not cover template string types. But this book is still a valuable resource.
3.3 Blogs
• My blog “2ality” is about TypeScript and JavaScript.
27
28 3 Free resources on TypeScript
• “Projects” by Josh Goldberg: “Hands on real world projects that will help you exer-
cise your knowledge of TypeScript.”
29
Chapter 4
31
32 4 The basics of TypeScript
This chapter explains the basics of TypeScript. After reading it, you should be able to write
your first TypeScript code. My hope is that that shouldn’t take you longer than a day. I’d
love to hear how long it actually took you – my guess may be off.
interface Array<T> {
concat(...items: Array<T[] | T>): T[];
reduce<U>(
callback: (state: U, element: T, index: number) => U,
firstState?: U
): U;
// ···
}
You may think that this is cryptic. And I agree with you! But (as I hope to prove) this
syntax is relatively easy to learn. And once you understand it, it gives you immediate,
precise and comprehensive summaries of how code behaves – without having to read long
descriptions in English.
4.2 How to play with code while reading this chapter 33
However, you may still want to play with TypeScript code. The following chapter explains
how to do that: “Trying out TypeScript without installing it” (§7).
• A type is a set of values. For example, the type boolean is a set whose elements are
false and true.
• S being a subtype of T means that S is a subset of T.
• The program level (JavaScript): At this level, using TypeScript source code means
running it: We have to remove the type information and feed it to a JavaScript en-
gine.
• The type level (TypeScript): At this level, using TypeScript source code means type-
checking it: We analyze the source code to make sure types are used consistently.
Its types are called dynamic. Why is that? We have to run code to see if they are used
correctly – e.g.:
In contrast, TypeScript’s types are static: We check them by analyzing the syntax – without
running the code. That happens during editing (for individual files) or when running
the TypeScript compiler tsc (for the whole code base). In the following code, TypeScript
detects the error via type checking (note that it doesn’t even need explicit type information
in this case):
typeof additionally has a separate “type” for functions but that is not how ECMAScript
sees things internally.
All of these types are dynamic. They can also be used at the type level in TypeScript (see
next section).
• Sources of data – e.g. values created via literals such as 128, true or ['a', 'b']
• Sinks of data – e.g. storage locations such as variables, properties and parameters.
– Storage locations can also become data sources when we read from them.
• The type of a data source describes what dynamic values it can be.
• The type of a data sink describes what dynamic values can be written to it.
One way in which a storage location such as a variable can receive a static type is via a type
annotation – e.g.:
The colon (:) plus the type number is the type annotation. It states that the static type of
the variable count is number. The type annotation helps with type checking:
What does the error message mean? The (implicit) static type string of the data source
'yes' is incompatible with the (explicitly specified) static type number of the data sink
count.
• At the dynamic level, we use JavaScript to declare a variable noValue and initialize
it with the value undefined.
• At the static level, we use TypeScript to specify that variable noValue has the static
type undefined.
The same syntax, undefined, is used at the JavaScript level and at the type level and means
different things – depending on where it is used.
36 4 The basics of TypeScript
The 1000 after the colon is a type, a number literal type: It is a set whose only element is the
value 1000 and it is a subtype of number.
thousand = 1000; // OK
// @ts-expect-error: Type '999' is not assignable to type '1000'.
thousand = 999;
On the other hand, we can assign thousand to any variable whose type is number because
its type is a subtype of number:
Especially string literal types will become useful later (when we get to union types).
• unknown is similar to any but less flexible: If a variable or parameter has that type, we
can also write any value to it. However, we can’t do anything with its content unless
we perform further type checks. Being less flexible is a good thing: I recommend
avoiding any and instead using unknown whenever possible. For more information
see “The top types any and unknown” (§14).
• never the empty set as a type. Among other things, it is used for locations that are
never reached when a program is executed.
4.7 Type inference 37
If strict type checking is enabled, we can only use any explicitly: Every location must
have an explicit or inferred static type. That is safer because there are no holes in type
checking, no unintended blind spots.
Let’s look at examples – the type of parameters can usually not be inferred:
For func3, TypeScript can infer that arg has the type boolean because it has the default
value false.
TypeScript infers that the type of count is 14. It can do so because it knows that the value
14 has the type 14. Interestingly, TypeScript infers a more general type when we use let:
Why is that? The assumption is that the value of count is preliminary and that we want to
assign other (similar!) values later on. If count had the type 14 then we wouldn’t be able
to do that.
Another example of type inference: In this case TypeScript infers that function toString(
) has the return type string.
38 4 The basics of TypeScript
• The function has one parameter, value. That parameter has the type any. If a pa-
rameter has that type, it accepts any kind of value. (More on any soon.)
• The function returns values of type string.
Step 3: By combining the results of step 1 and step 2, TypeScript can infer that strValue
has the type string.
// Array types
type StringArray = Array<string>;
// Function types
type NumToStr = (num: number) => string;
};
// Union types
type YesOrNo = 'yes' | 'no';
• An Array type T[] or Array<T> is used if an Array is a collection of values that all
have the same type T.
• A tuple type [T0, T1, ···] is used if the index of an Array element determines its
type.
Normally, TypeScript can infer the type of a variable if there is an assignment. In this case,
we have to help it because with an empty Array, it can’t determine the type of the elements.
We’ll explore the angle brackets notation of Array<number> in more detail later (spoiler:
Array is a generic type and number is a type parameter).
assert.deepEqual(
Object.fromEntries([entry]),
{
count: 33,
}
);
What is the nature of entry? At the JavaScript level, it’s also an Array, but it is used dif-
ferently:
This type comprises every function that accepts a single parameter of type number and
returns a string. Let’s use this type in a type annotation:
Because TypeScript knows that toString has the type NumToStr, we do not need type an-
notations inside the arrow function.
Note that we specified both a type for the parameter num and a return type. The inferred
type of toString is:
assertType<
(num: number) => string
>(toString);
Due to the type of the parameter callback, TypeScript rejects the following function call:
assert.equal(
stringify123(String), '123'
);
const stringify123 =
(callback: (num: number) => string): string => callback(123);
4.11 Function types 41
It may do so explicitly:
Or it may do so implicitly:
However, such a function cannot explicitly return values other than undefined:
TypeScript only lets us make the function call in line A if we make sure that callback isn’t
undefined (which it is if the parameter was omitted).
assert.deepEqual(
createPoint(),
42 4 The basics of TypeScript
[0, 0]);
assert.deepEqual(
createPoint(1, 2),
[1, 2]);
Default values make parameters optional. We can usually omit type annotations, because
TypeScript can infer the types. For example, it can infer that x and y both have the type
number.
• Dictionary object: An arbitrary number of properties whose names are not known
at development time. All properties have the same type.
We are ignoring dictionary objects in this chapter – they are covered in “Index signatures:
objects as dictionaries” (§18.7). As an aside, Maps are usually a better choice for dictionar-
ies, anyway.
type Point = {
x: number,
y: number,
};
4.12 Typing objects 43
We can also use semicolons instead of commas to separate members, but the latter are more
common.
The members can also be separated by semicolons instead of commas but since the syntax
of object literals types is related to the syntax of object literals (where members must be
separated by commas), commas are used more often.
interface Point {
x: number;
y: number;
} // no semicolon!
The members can also be separated by commas instead of semicolons but since the syn-
tax of interfaces is related to the syntax of classes (where members must be separated by
semicolons), semicolons are used more often.
type Point = {
x: number,
y: number,
};
function pointToString(pt: Point) {
return `(${pt.x}, ${pt.y})`;
}
assert.equal(
pointToString({x: 5, y: 7}), // compatible structure
'(5, 7)');
Conversely, in Java’s nominal type system, we must explicitly declare with each class
which interfaces it implements. Therefore, a class can only implement interfaces that exist
at its creation time.
type Person = {
name: string,
company?: string,
};
In the following example, both john and jane match the type Person:
44 4 The basics of TypeScript
4.12.5 Methods
Object literal types can also contain methods:
type Point = {
x: number,
y: number,
distance(other: Point): number,
};
As far as TypeScript’s type system is concerned, method definitions and properties whose
values are functions, are equivalent:
type HasMethodDef = {
simpleMethod(flag: boolean): void,
};
type HasFuncProp = {
simpleMethod: (flag: boolean) => void,
};
type _ = Assert<Equal<
HasMethodDef,
HasFuncProp
>>;
const objWithMethod = {
simpleMethod(flag: boolean): void {},
};
assertType<HasMethodDef>(objWithMethod);
assertType<HasFuncProp>(objWithMethod);
assert.equal(getScore('*****'), 5);
assert.equal(getScore(3), 3);
stringOrNumber has the type string|number. The result of the type expression s|t is the
set-theoretic union of the types s and t (interpreted as sets).
Note that TypeScript does not force us to initialize immediately (as long as we don’t read
from the variable before initializing it):
Unions of string literals provide a quick way of defining a type with a limited set of values.
For example, this is how the Node.js types define the buffer encoding that you can use (e.g.)
with fs.readFileSync():
type BufferEncoding =
| 'ascii'
| 'utf8'
| 'utf-8'
| 'utf16le'
| 'utf-16le'
| 'ucs2'
| 'ucs-2'
| 'base64'
| 'base64url'
| 'latin1'
| 'binary'
| 'hex'
;
It’s neat that we get auto-completion for such unions (figure 4.1). We can also rename
the elements of the union everywhere they are used – via the same refactoring that also
changes function names.
Figure 4.1: The auto-completion for BufferEncoding shows all elements of the union type.
4.14 Intersection types 47
One key use case for intersection types is combining object types (more information).
In the following code, we narrow the type of value via the type guard typeof:
It’s interesting to see how the type of value changes, due to us using typeof in the condition
of an if statement:
Similarly:
• Normal functions exist at the dynamic level, are factories for values and have pa-
rameters representing values. Parameters are declared between parentheses:
• Generic types exist at the static level, are factories for types and have parameters
representing types. Parameters are declared between angle brackets:
Value is a type variable. One or more type variables can be introduced between angle brack-
ets.
class SimpleStack<Elem> {
#data: Array<Elem> = [];
push(x: Elem): void {
this.#data.push(x);
}
pop(): Elem {
const result = this.#data.pop();
4.16 Type variables and generic types 49
Class SimpleStack has the type parameter Elem. When we instantiate the class, we also
provide a value for the type parameter:
Thanks to type inference (based on the argument of new Map()), we can omit the type
parameters:
Due to type inference, we can once again omit the type parameter:
50 4 The basics of TypeScript
const obj = {
identity<Arg>(arg: Arg): Arg {
return arg;
},
};
We can omit the type parameter when calling fillArray() (line A) because TypeScript can
infer T from the parameter elem:
interface Array<T> {
concat(...items: Array<T[] | T>): T[];
reduce<U>(
4.18 Next steps 51
• method .concat():
– Has zero or more parameters (defined via a rest parameter). Each of those
parameters has the type T[]|T. That is, it is either an Array of T values or a
single T value. That means that the values in items have the same type T as the
values in this (the receiver of the method call).
– Returns an Array whose elements also have the type T.
• method .reduce() introduces its own type variable U. U is used to express the fact
that the following entities all have the same type:
You may be tempted to use settings that produce fewer compiler errors. However, with-
out strict checking, TypeScript simply doesn’t work as well and will detect far fewer
problems in your code.
This chapter explains functionality that is used in the code examples to explain results and
errors. We have to consider two levels:
The functions and generic types that help us, have to be imported: The import statements
to do so are shown in this chapter, but omitted elsewhere in this book.
assert.equal(
3 + ' apples',
53
54 5 Notation used in this book
'3 apples'
);
assert.deepEqual(
[...['a', 'b'], ...['c', 'd']],
['a', 'b', 'c', 'd']
);
assert.throws(
() => Object.freeze({}).prop = true,
/^TypeError: Cannot add property prop, object is not extensible/
);
In the first line, the specifier of the imported module has the suffix /strict. That enables
strict assertion mode, which uses === and not == for comparisons.
The function call assertType<T>(v) asserts that the (dynamic) value v has the (static) type
T:
asserttt has several predicates (generic types that construct booleans) that we can use with
Assert<>. In the previous example, we have used:
• Equal<T1, T2>
• Not<B>
5.4 Type level: @ts-expect-error 55
In other words: TypeScript checks that there is an error but not what error it is. All text
after @ts-expect-error is ignored (including the colon).
To get more thorough checks, I use the tool ts-expect-error which checks if the sup-
pressed error messages match the texts after @ts-expect-error:.
This book uses in-code checks (as described above) even though that doesn’t look as nice.
Why?
• This notation makes you think about types in terms of tests. That prepares you for
computed types and for coding exercises – whose notation is similar.
• The notation makes it possible to test the code examples automatically, via the Markcheck
tool for Markdown. That ensures that they don’t contain errors. Twoslash only spec-
ifies which types to display; it does not check that those types are as expected.
• For printed books, HTML still isn’t where I’d like it to be. Thus, I can’t use Shiki
Twoslash there.
• Minor downside of Shiki Twoslash: You need to run the TypeScript type checker in
order to render a book. With my notation, I only need to run it when I check the
code examples.
56 5 Notation used in this book
Chapter 6
Read this chapter if you are a JavaScript programmer and want to get a rough idea of what
using TypeScript is like (think first step before learning more details). You’ll get answers
to the following questions:
57
58 6 How TypeScript is used: workflows, tools, etc.
This chapter focuses on how TypeScript works. If you want to know more about why it is
useful, see “Sales pitch for TypeScript” (§2).
• The JavaScript syntax is what is run and what exists at runtime: In order to run
TypeScript code, the type syntax must be removed – via compilation that results in
pure JavaScript. That code is executed by a JavaScript engine.
• The type syntax is only used during editing and compiling; it has no effect at run-
time:
– On one hand, it supports type checking which reports errors if there are inconsis-
tencies within the types or between the types and the JavaScript values. Type
checking runs during editing and during compiling.
– On the other hand, the types improve editing via auto-completion, type hints,
refactorings, etc.
If we want to run this code, we have to remove the type syntax and get JavaScript that is
executed by a JavaScript engine:
function add(x, y) {
return x + y;
}
ts-app/
tsconfig.json
src/
main.ts
util.ts
util_test.ts
test/
integration_test.ts
6.2 Ways of running TypeScript code 59
Let’s explore the different ways in which we can run this code.
cd ts-app/
node src/main.ts
• Prior to HTTP/2, only one file could be served per connection. But that benefit of
bundling is not relevant anymore.
– Each file the client has to request and process, still incurs a little overhead (even
though no new connection is opened).
• Web servers don’t have to serve many (often small) files – which helps with effi-
ciency.
• A single large file can be compressed better than many small files.
Most bundlers support TypeScript – either directly or via plugins. That means, we run our
TypeScript code via the JavaScript file bundle.js that was produced by a bundler:
ts-app/
tsconfig.json
src/
main.ts
util.ts
util_test.ts
test/
integration_test.ts
dist/
bundle.js
Compiling source code to source code is also called transpiling. tsconfig.json specifies
where the transpilation output is written. Let’s assume we write it to the directory dist/:
60 6 How TypeScript is used: workflows, tools, etc.
ts-app/
tsconfig.json
src/
main.ts
util.ts
util_test.ts
test/
integration_test.ts
dist/
src/
main.js
util.js
util_test.js
test/
integration_test.js
By default, TypeScript does not change the specifiers of imported modules. Therefore,
code that is transpiled must look like this (we import util.js, from JavaScript code):
// main.ts
import {helperFunc} from './util.js';
However, such code does not work if we run it directly. There, we must write (we import
util.ts from TypeScript code):
// main.ts
import {helperFunc} from './util.ts';
We can also tell TypeScript to change the filename extensions of local imports from .ts to
.js (more information). Then the previous code can also be transpiled.
• Essential:
– lib.js: the JavaScript part of lib.ts
– lib.d.ts: the type part of lib.ts (a declaration file)
• Optional: source maps. They map source code locations of compilation output to
lib.ts.
– lib.js.map: source map for lib.js
– lib.d.ts.map: source map for lib.d.ts
6.3 Publishing a library package to the npm registry 61
ts-lib/
package.json
tsconfig.json
src/
lib.ts
dist/
lib.js
lib.js.map
lib.d.ts
lib.d.ts.map
• package.json is npm’s description of our library package. Some of its data, such
as the so-called package exports, are also used by TypeScript – e.g. to look up type
information when someone imports from our package.
• Every file in dist/ was generated by TypeScript. While it is uploaded to the npm
registry, it is usually not added to version control systems because it can easily be
regenerated.
• Only tsconfig.json is not uploaded to the npm registry.
Actually, behind the scenes, many editors (e.g. Visual Studio Code) use a kind of lightweight
TypeScript mode when editing JavaScript code so that we also get simple type checking
and code completion there.
Notes:
• lib.js.map: maps lib.js locations to lib.ts locations and gives us debugging and
stack traces for the latter when we run the former.
• lib.d.ts.map: maps lib.d.ts lines to lib.ts lines. It enables “go to definition” for
imports from lib.ts to take us to that file.
All source-map-related functionality except stack traces require access to the original Type-
Script source code. That’s why it makes sense to include lib.ts if there are source maps.
{
"version": 3,
"file": "lib.js",
"sourceRoot": "",
"sources": [
"../../src/lib.ts"
],
"names": [],
"mappings": "AAAA,uBAAuB;AACvB,MAAM,UAAU,···"
}
{
"version": 3,
"file": "lib.d.ts",
"sourceRoot": "",
"sources": [
"../../src/lib.ts"
],
"names": [],
6.4 DefinitelyTyped: a repository with types for type-less npm packages 63
"mappings": "AAAA,uBAAuB;AACvB,wBAAgB,GAAG,···"
}
In both cases, the actual content of "mappings" was abbreviated. And in the actual output
of tsc, the JSON is always squeezed into a single line.
One important DefinitelyTyped package for Node.js is @types/node with types for all of
its APIs. If you develop TypeScript on Node.js, you will usually have this package as a
development dependency.
##3 is so complex that only tsc can do it. However, for both #1 and #2, there are slightly
simpler subsets of TypeScript where compilation does not involve much more than syn-
tactic processing. That means that we can use external, faster tools for #1 and #2.
There are even tsconfig.json settings to warn us if we don’t stay within those subsets of
TypeScript (more information). Doing that is not much of a sacrifice in practice.
1. Type syntax can be detected and removed by only parsing the syntax – without
performing additional semantic analyses.
2. No non-type language features are transpiled. In other words: Removing the type
syntax is enough to produce JavaScript.
#2 means that there are several TypeScript features that we can’t use – e.g., enums and JSX
(HTML-like syntax inside TypeScript, as used, e.g., by React).
One considerable benefit of type stripping is that it does not need any configuration (via
tsconfig.json or other means) because it’s so simple. That makes platforms that use it
more stable w.r.t. changes made to TypeScript.
64 6 How TypeScript is used: workflows, tools, etc.
One clever technique for type stripping was pioneered by the ts-blank-space tool (by
Ashley Claymore for Bloomberg): Instead of simply removing the type syntax, it replaces it
with spaces. That means that source code positions in the output don’t change. Therefore,
any positions that show up (e.g.) in stack traces still work for the input and there is less
of a need for source maps: You still need them for debugging and going to definitions but
JavaScript generated by type stripping is relatively close to the original TypeScript and
you are often OK even then.
Output (JavaScript):
function add(x , y ) {
return x + y;
}
If you want to explore further, you can check out the ts-blank-space playground.
The following example shows how the isolated declaration style changes code:
Note that isolated declarations only affect constructs that are exported. Module-internal
code does not show up in declaration files.
6.6 JSR – the JavaScript registry 65
In contrast, with the npm registry, your TypeScript library package is only usable on
Node.js if you upload .js files and .d.ts files.
JSR also provides several features that npm doesn’t such as automatic generation of doc-
umentation. See “Why JSR?” in the official documentation for more information.
The observations in this section are about Visual Studio Code, but may apply to other IDEs,
too.
With Visual Studio Code, we get two different ways of type checking:
• Any file that is currently open is automatically type-checked within Visual Studio
Code. It order to provide that functionality, it comes with its own installation of
TypeScript.
• If we want to type-check all of a code base, we must invoke the TypeScript compiler
tsc. We can do that via Visual Studio Code’s tasks – a built-in way of invoking exter-
nal tools (for type checking, compiling, bundling, etc.). The official documentation
has more information on tasks.
66 6 How TypeScript is used: workflows, tools, etc.
/**
* @param {number} x - The first operand
* @param {number} y - The second operand
* @returns {number} The sum of both operands
*/
function add(x, y) {
return x + y;
}
• No need for a build step to run the code – even on platforms (such as browsers) that
don’t support TypeScript.
– We can also generate .d.ts files from .js files with JSDoc comments. That
is an extra build step, though. How to do that is explained in the TypeScript
Handbook.
• It enables us to make a JavaScript code base more type-safe – in small incremental
steps.
interface Point {
x: number;
y: number;
/** optional property */
z?: number;
}
/**
* @typedef Point
* @prop {number} x
* @prop {number} y
* @prop {number} [z] optional property
*/
The Playground is very useful for quick experiments and demos. It can save both Type-
Script code snippets and compiler settings into URLs, which is great for sharing such snip-
pets with others. This is an example of such a URL:
https://www.typescriptlang.org/play/?#code/«base64»
Many social media services limit the characters per post, but not the characters per URL.
Therefore, we can use Playground URLs to share code that wouldn’t fit into a post.
67
68 7 Trying out TypeScript without installing it
– The filename extension is .mts so that we don’t need a package.json file to tell
Node.js that .ts means ESM module.
• We edit playground.mts. Whenever we save it, Node.js re-runs it and shows its
output.
Note that Node.js does not type-check the code. But the type checking we get in TypeScript
editors should be enough in this case (since we are only working with a single file).
I have created the GitHub repository nodejs-type-stripping where both are already set
up correctly.
• MacOS: pbpaste
• Windows PowerShell: Get-Clipboard
7.3 Running copied TypeScript code via Node.js 69
• Linux: There are many options. wl-clipboard worked well for me on Ubuntu, where
I installed it via the App Center (snap).
Setting up TypeScript
71
Chapter 8
Guide to tsconfig.json
73
74 8 Guide to tsconfig.json
8.8.2 Generating .js files via type stripping: erasableSyntaxOnly and ver
batimModuleSyntax . . . . . . . . . . . . . . . . . . . . . . . . . . 88
8.8.3 erasableSyntaxOnly: no transpiled language features . . . . . . . . 88
8.8.4 verbatimModuleSyntax: enforcing type in imports and exports . . . 89
8.8.5 isolatedDeclarations: generating .d.ts files more efficiently . . . 90
8.9 Importing CommonJS from ESM . . . . . . . . . . . . . . . . . . . . . . . 92
8.9.1 allowSyntheticDefaultImports: type-checking default imports of
CommonJS modules . . . . . . . . . . . . . . . . . . . . . . . . . . 92
8.9.2 esModuleInterop: better compilation of TypeScript to CommonJS code 92
8.10 One more option with a good default . . . . . . . . . . . . . . . . . . . . 93
8.11 Visual Studio Code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
8.12 Summary: Assemble your tsconfig.json by answering four questions . . 93
8.12.1 Do you want to transpile new JavaScript to older JavaScript? . . . . 95
8.12.2 Should TypeScript only allow JavaScript features at the non-type level? 95
8.12.3 Which filename extension do you want to use in local imports? . . 95
8.12.4 What files should tsc emit? . . . . . . . . . . . . . . . . . . . . . . 95
8.13 Further reading . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96
8.13.1 tsconfig.json recommendations by other people . . . . . . . . . . 96
8.13.2 Sources of this chapter . . . . . . . . . . . . . . . . . . . . . . . . 96
This chapter documents all common options of the TypeScript configuration file tsconfig.
json:
• This knowledge will enable you to understand and simplify your tsconfig.json.
• If you don’t have the time to read the chapter, you can jump to the summary at the
end where I show a starter tsconfig.json file with all settings – along with four
questions to determine which settings you can delete.
• Importing and type-checking plain JavaScript in your code base, namely the options
allowJs and checkJs.
• How to set up JSX. See “JSX” in the TypeScript Handbook.
8.2 Extending base files via extends 75
• “Projects” (useful for monorepos): option composite etc. For more information on
this topic, see:
– Chapter “Project References” in the TypeScript Handbook
– My blog post “Simple monorepos via npm workspaces and TypeScript project
references”
The GitHub repository tsconfig/bases lists bases that are available under the npm names-
pace @tsconfig and can be used like this (after they were installed locally via npm):
{
"extends": "@tsconfig/node-lts/tsconfig.json",
}
Alas, none of these files suit my needs. But they can serve as an inspiration for your tscon-
fig.
On one hand, we have to tell TypeScript what the input files are. These are the available
options:
{
"include": ["src/**/*"],
"compilerOptions": {
// Specify explicitly (don’t derive from source file paths):
"rootDir": "src",
"outDir": "dist",
// ···
}
}
• Input: src/util.ts
– Output: dist/util.js
• Input: src/test/integration_test.ts
– Output: dist/test/integration_test.js
I like the idea of having a separate directory test/ that is a sibling of src/. However then
the output files in dist/ are more deeply nested inside the project’s directory than the
input files in src/ and test/. That means that we can’t access files such as package.json
via relative module specifiers.
tsconfig.json:
{
"include": ["src/**/*", "test/**/*"],
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
// ···
}
}
• Input: src/util.ts
– Output: dist/src/util.js
• Input: test/integration_test.ts
– Output: dist/test/integration_test.js
The default value of rootDir depends on the input file paths. I find that too unpredictable
and always specify it explicitly. It is the longest common prefix of the input file paths.
tsconfig.json:
{
"include": ["src/**/*"],
}
Files:
/tmp/my-proj/
tsconfig.json
src/
main.ts
test/
test.ts
dist/
main.js
test/
test.js
tsconfig.json:
{
"include": ["src/**/*"],
}
Files:
/tmp/my-proj/
tsconfig.json
src/
core/
cli/
main.ts
test/
test.ts
dist/
main.js
test/
test.js
Example 3:
{
"include": ["src/**/*", "test/**/*"],
}
Files:
/tmp/my-proj/
tsconfig.json
78 8 Guide to tsconfig.json
src/
main.ts
test/
test.ts
dist/
src/
main.js
test/
test.js
sourceMap produces source map files that point from the transpiled JavaScript to the orig-
inal TypeScript. That helps with debugging and is usually a good idea.
"compilerOptions": {
"declaration": true,
"declarationMap": true, // enables importers to jump to source
}
Optionally, we can include the TypeScript source code in our npm package and activate
declarationMap. Then importers can, e.g., click on types or go to the definition of a value
and their editor will send them to the original source code.
Option declarationDir
By default, each .d.ts file is put next to its .js file. If you want to change that, you can
use option declarationDir.
• newLine configures the line endings for emitted files. Allowed values are:
– "lf": ”n” (Unix)
– "crlf": ”rn” (Windows)
8.5 Language and platform features 79
8.5.1 target
target determines which newer JavaScript syntax is transpiled to older syntax. For ex-
ample, if the target is "ES5" then an arrow function () => {} is transpiled to a function
expression function () {}. Values can be:
• "ESNext"
• "ES5"
• "ES6"
• "ES2015" (same as "ES6")
• "ES2016"
• Etc.
"ESNext" means that nothing is ever transpiled. I find that setting easiest to deal with. It’s
also the best setting if you don’t use tsc and use type stripping (which never transpiles
anything either).
If we want to transpile, we have to pick an ECMAScript version that works for our target
platforms. There are two tables that provide good overviews:
Additionally, the official tsconfig bases all provide values for target.
8.5.2 lib
lib determines which types for built-in APIs are available – e.g. Math or methods of built-in
types:
• There are categories such as "ES2024" and "DOM" and subcategories such as "DOM.
Iterable" and "ES2024.Promise".
target determines the default value of lib: If the latter is omitted and target is "ES20YY"
then "ES20YY.Full" is used. However, that is not a value we can use ourselves. If we want
to replicate what removing lib does, we have to enumerate the contents of (e.g.) es2024.
full.d.ts in the TypeScript source code repository ourselves:
8.5.3 skipLibCheck
• skipLibCheck:false – By default, TypeScript type-checks all .d.ts files. This is nor-
mally not necessary but helps when a project contains hand-written .d.ts files.
"compilerOptions": {
"module": "NodeNext",
"noUncheckedSideEffectImports": true,
}
Option module
With this option, we specify systems for handling modules. If we set it up correctly, we
also take care of the related option moduleResolution, for which it provides good defaults.
The TypeScript documentation recommends either of the following two values:
• Node.js: "NodeNext" supports both CommonJS and the latest ESM features.
– Implies "moduleResolution": "NodeNext"
– Downside of "NodeNext": It’s a moving target. But generally, functionality is
only added.
– Upside of "NodeNext": It supports a good mix of features – for example (source):
* "Node16" does not support import attributes (which are needed for im-
porting JSON files).
* "Node18" and "Node20" support the outdated import assertions.
* require(esm) (which is only relevant for CommonJS code, not for ESM
code) is only supported by "Node20" and "NodeNext".
• Bundlers: "Preserve" supports both CommonJS and the latest ESM features. It
matches what most bundlers do.
– Implies "moduleResolution": "bundler"
Given that bundlers mostly mimic what Node.js does, I’m always using "NodeNext" and
haven’t encountered any issues.
Note that in both cases, TypeScript forces us to mention the complete names of local mod-
ules we import. We can’t omit filename extensions as was frequent practice when Node.js
was only compiled to CommonJS. The new approach mirrors how pure-JavaScript ESM
works.
module:NodeNext implies target:ESNext but in this case, I prefer to manually set up target
because module and target are not as closely related as module and moduleResolution.
Furthermore, module:Bundler does not imply anything.
Option noUncheckedSideEffectImports
By default, TypeScript does not complain if an empty import does not exist. The reason
for this behavior is that this is a pattern supported by some bundlers to associate non-
TypeScript artifacts with modules. And TypeScript only sees TypeScript files. This is what
such an import looks like:
import './component-styles.css';
Interestingly, TypeScript normally is also OK with emptily imported TypeScript files that
don’t exist. It only complains if we import something from a non-existent file.
Most non-browser JavaScript platforms now can run TypeScript code directly, without
transpiling it.
This mainly affects what filename extension we use when we import a local module. Tra-
ditionally, TypeScript does not change module specifiers and we have to use the filename
extension .js in ESM modules (which is what works in the JavaScript that our TypeScript
is compiled to):
Related option:
• If you want to use tsc only for type checking, then take a look at the noEmit option.
• More information on type stripping and option erasableSyntaxOnly that helps with
it
• Node’s official documentation on its TypeScript support
There are two ways in which we can prevent TypeScript from raising errors for unknown
imports.
First, we can use option allowArbitraryExtensions to prevent any kind of error reporting
in this case.
Second, we can create an ambient module declaration with a wildcard specifier – a .d.ts
file that has to be somewhere among the files that TypeScript is aware of. The following
example suppresses errors for all imports with the filename extension .css:
// ./src/globals.d.ts
declare module "*.css" {}
strict is a must, in my opinion. With the remaining settings, you have to decide for
yourself if you want the additional strictness for your code. You can start by adding all of
them and see which ones cause too much trouble for your taste.
8.7.1 strict
The compiler setting strict provides an important minimal setting for type checking. In
principle, this setting would default to true but backward compatibility makes that im-
possible.
strict activates the following settings (which won’t be mentioned again in this chapter):
• alwaysStrict: always emit "use strict" in script files. That’s a legacy JavaScript
feature that’s not needed in ECMAScript modules.
• noImplicitAny: If true, we can omit types in some locations (mainly parameter def-
initions) and TypeScript will (implicitly) infer the type any. If false, we must pro-
vide explicit type annotations – which can use the type any (explicitly). For more
information, see “The compiler option noImplicitAny” (§14.2.3).
• noImplicitThis: If we use this in ordinary functions, we must explicitly declare its
type.
• strictBindCallApply: If true, TypeScript will check that we pass correct arguments
to .call(), .apply() and .bind(). If false, we can pass any arguments to those
methods.
• strictBuiltinIteratorReturn: If active, built-in iterators have the TReturn type
undefined (instead of any).
• strictFunctionTypes: If true, compatibility between function types is handled more
correctly.
• strictNullChecks: If true, the values undefined and null are not elements of nor-
mal types T. If we want to accept them, we have to use the type undefined | T or
null | T, respectively.
• strictPropertyInitialization: If true, TypeScript warns us if we don’t initialize a
class instance property in the constructor. For more information, see “Strict property
initialization” (§21.4.1).
• useUnknownInCatchVariables: If true, TypeScript gives catch variables without type
annotations the type unknown (instead of any).
8.7.2 exactOptionalPropertyTypes
If true then .colorTheme can only be omitted and not be set to undefined:
interface Settings {
// Absent property means “system”
colorTheme?: 'dark' | 'light';
}
const obj1: Settings = {}; // allowed
// @ts-expect-error: Type '{ colorTheme: undefined; }' is not
// assignable to type 'Settings' with
// 'exactOptionalPropertyTypes: true'. Consider adding 'undefined'
// to the types of the target's properties.
const obj2: Settings = { colorTheme: undefined };
This option also prevents optional tuple elements being undefined (vs. missing):
I’m ambivalent about this option: On one hand, enabling it prevents useful patterns such
as:
type Obj = {
num?: number,
};
function createObj(num?: number): Obj {
// @ts-expect-error: Type '{ num: number | undefined; }' is not
// assignable to type 'Obj' with
// 'exactOptionalPropertyTypes: true'.
return { num };
}
On the other hand, it does better reflect how JavaScript works – e.g., spreading distin-
guishes missing properties and properties whose values are undefined:
If we had assigned an empty object in line A then the value of result would be {a:1} and
match its type.
8.7.3 noFallthroughCasesInSwitch
If true, non-empty switch cases must end with break, return or throw.
86 8 Guide to tsconfig.json
8.7.4 noImplicitOverride
If true then methods that override superclass methods must have the override modifier.
8.7.5 noImplicitReturns
If true then an “implicit return” (the function or method ending) is only allowed if the
return type is void.
8.7.6 noPropertyAccessFromIndexSignature
If true then for types such as the following one, we cannot use the dot notation for un-
known properties, only for known ones:
interface ObjectWithId {
id: string,
[key: string]: string;
}
function f(obj: ObjectWithId) {
const value1 = obj.id; // allowed
const value2 = obj['unknownProp']; // allowed
// @ts-expect-error: Property 'unknownProp' comes from an index
// signature, so it must be accessed with ['unknownProp'].
const value3 = obj.unknownProp;
}
8.7.7 noUncheckedIndexedAccess
noUncheckedIndexedAccess and objects
interface ObjectWithId {
id: string,
[key: string]: string;
}
function f(obj: ObjectWithId): void {
assertType<string>(obj.id);
assertType<undefined | string>(obj['unknownProp']);
}
One common pattern for Arrays is to check the length before accessing an element. How-
ever, that pattern becomes inconvenient with noUncheckedIndexedAccess:
• allowUnreachableCode
• allowUnusedLabels
• noUnusedLocals
• noUnusedParameters
1. Type checking
2. Emitting JavaScript files
3. Emitting declaration files
External tools have become popular that do #2 and #3 much faster. The following subsec-
tions describe configuration options that help those tools.
88 8 Guide to tsconfig.json
Sometimes, we want to use tsc only for type checking – e.g., if we run TypeScript directly
or use external tools for compiling TypeScript files (to JavaScript files, declaration files,
etc.):
• noEmit: If true, we can run tsc and it will only type-check the TypeScript code, it
won’t emit any files.
In principle, you don’t have to provide output-related settings such as rootDir and outDir
anymore. However, some external tools may need them.
Type stripping is a simple and fast way of compiling TypeScript to JavaScript. It’s what
Node.js uses when it runs TypeScript. Type stripping is fast because it only supports a
subset of TypeScript where two things are possible:
1. Type syntax can be detected and removed by only parsing the syntax – without
performing additional semantic analyses.
2. No non-type language features are transpiled. In other words: Removing the type
syntax is enough to produce JavaScript.
To help with type stripping, TypeScript has two compiler options that report errors if we
use unsupported features:
Useful related knowledge: “Type stripping technique: replacing types with spaces” (§6.5.1.1).
• JSX
• Enums
• Parameter properties in class constructors.
8.8 Compiling TypeScript with tools other than tsc 89
• Namespaces
• Future JavaScript that is compiled to current JavaScript
Another feature that is forbidden by erasableSyntaxOnly is the legacy way of casting via
angle brackets – because its syntax makes type stripping impossible in some cases (source):
When compiling TypeScript to JavaScript via type stripping, we need to remove the Type-
Script parts. Most of those parts are easy to detect. The exception are imports and exports
– e.g., without semantic analysis, we don’t know if an import is a (TypeScript) type or a
(JavaScript) value. If type-only imports and exports are marked with the keyword type,
no such analysis is necessary.
Importing types
// Input: TypeScript
import { type SomeInterface, SomeClass } from './my-module.js';
// Output: JavaScript
import { SomeClass } from './my-module.js';
Note that a class is both a value and a type. In that case, no type keyword is needed because
that part of the syntax can stay in plain JavaScript.
Exporting types
Export clauses:
type DefaultType = {}
export default DefaultType; // error
export default type DefaultType; // error
type DefaultType = {}
export {
type DefaultType as default,
}
Why does this inconsistency exist? type is allowed as a (JavaScript-level) identifier after
export default.
isolatedModules
• The tools don’t need to know the logic of type inference – which makes them simpler.
Extracting declarations becomes a syntactic operation and doesn’t really have to
consider the type level.
8.8 Compiling TypeScript with tools other than tsc 91
isolatedDeclarations only produces compiler errors, it does not change what is emitted
by tsc. It only affects constructs that are exported – because only those show up in decla-
ration files. Module-internal code is not affected.
More complicated variable declarations must have type annotations. Note that this only
affects top-level declarations – e.g.: Variable declarations inside functions don’t show up
in declaration files and therefore don’t matter.
Class instance fields must have type annotations (even though tsc can infer their types if
there is an assignment in the constructor):
export class C {
str: string; // required
constructor(str: string) {
this.str = str;
}
}
92 8 Guide to tsconfig.json
I’d love to always use isolatedDeclarations, but TypeScript only allows it if option dec
laration or option composite are active. Jake Bailey explains why that is:
Further reading
The TypeScript 5.5 release notes have a comprehensive section on isolated declarations.
• In ESM, the default export is the property .default of the module namespace object.
• In CommonJS, the module object is the default export – e.g., there are many Com-
monJS modules that set module.exports to a function.
This reflects how Node.js handles default imports of CommonJS modules (source): “When
importing CommonJS modules, the module.exports object is provided as the default ex-
port. Named exports may be available, provided by static analysis as a convenience for
better ecosystem compatibility.”
• If false:
– import * as m from 'm' is compiled to const m = require('m').
8.10 One more option with a good default 93
javascript.preferences.importModuleSpecifierEnding
typescript.preferences.importModuleSpecifierEnding
By default, VSC should now be smart enough to add filename extensions where necessary.
Alternatively, you can use my interactive tsconfig configurator via the command line or
online.
{
"include": ["src/**/*"],
"compilerOptions": {
// Specified explicitly (not derived from source file paths)
"rootDir": "src",
"outDir": "dist",
"compilerOptions": {
// Transpile new JavaScript to old JavaScript
"target": "ES20YY", // sets up "lib" accordingly
}
The starter tsconfig only allows erasable syntax. If you want to use any of the aforemen-
tioned features, then remove section “Only JS at non-type level”.
• "rootDir"
• "outDir"
Emitted files:
File tsconfig.json
*.js Default (deactivated via "noEmit": true)
*.js.map "sourceMap": true
*.d.ts "declaration": true
*.d.ts.map "declarationMap": true
Source maps (.map) are only emitted if their source files are emitted.
96 8 Guide to tsconfig.json
In this chapter, we’ll create an ESM-based library package for npm via TypeScript:
• The described setup has worked well for me since TypeScript 4.7 (2022-05-24).
• We’ll only use tsc, but the setup is ready for other tools. For more information, see
“Compiling TypeScript with tools other than tsc” (§8.8).
• If you want to create a package with an executable and not a library, check out “Bin
scripts: shell commands written in JavaScript” (§9.3.7).
97
98 9 Publishing npm packages with TypeScript
my-package/
README.md
LICENSE
package.json
tsconfig.json
docs/
api/
src/
test/
dist/
test/
Comments:
9.1.1 .gitignore
I’m using Git for version control. This is my .gitignore (located inside my-package/)
node_modules
dist
.DS_Store
• node_modules: The most common practice currently seems to be not to check in the
node_modules directory.
9.2 tsconfig.json 99
• dist: The compilation output of TypeScript is not checked into Git, but it is uploaded
to the npm registry. More on that later.
• .DS_Store: This entry is about me being lazy as a macOS user. Since it’s only need
on that operating system, you can argue that Mac people should add it via a global
configuration setting and keep it out of project-specific gitignores.
src/
util.ts
util_test.ts
Given that unit tests help with understanding how a module works, it’s useful if they are
easy to find.
If an npm package has "exports", it can self-reference them via its package name:
// src/misc/errors.ts
import {helperFunc} from 'my-package/misc/errors.js';
The Node.js documentation has more information on self-referencing and notes: “Self-
referencing is available only if package.json has "exports", and will allow importing only
what that "exports" (in the package.json) allows.”
Benefits of self-referencing:
• It is useful for tests (which can demonstrate how importing packages would use the
code).
• It checks if your package exports are set up properly.
9.2 tsconfig.json
“Summary: Assemble your tsconfig.json by answering four questions” (§8.12) helps us
with creating a tsconfig.json file by asking us four questions. Let’s answer these ques-
tions for our npm package:
– A: Yes because that keeps things simple and lets us use type stripping should
we want to. It’s a forward-looking way of writing TypeScript.
code initially used .js. Now TypeScript transpilation can change .ts to .js.
Therefore, we can use .ts – with the benefit that the same code also runs with-
out transpilation on some platforms (Node.js, Deno, Bun, etc.). The only caveat
is that we still have to use .js when we self-reference modules (see previous
section) or use import().
{
"include": ["src/**/*"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
// ···
}
}
• Input: src/util.ts
– Output: dist/util.js
• Input: src/test/integration_test.ts
– Output: dist/test/integration_test.js
What if we want src/ and test/ to sit next to each other? See “Putting src/ and test/
next to each other” (§8.4.1.1) for more information.
9.2.2 Output
Given a TypeScript file util.ts, tsc writes the following output to dist/:
src/
util.ts
dist/
util.js
util.js.map
util.d.ts
util.d.ts.map
• util.js.map: source map for the JavaScript code. It enables the following function-
ality when running util.js:
9.3 package.json
Some settings in package.json also affect TypeScript. We’ll look at those next. Related
material:
"type": "module",
"files": [
"package.json",
"README.md",
"LICENSE",
"src/**/*.ts",
"dist/**/*.js",
"dist/**/*.js.map",
"dist/**/*.d.ts",
"dist/**/*.d.ts.map",
"!src/test/",
"!src/**/*_test.ts",
"!dist/test/",
"!dist/**/*_test.js",
"!dist/**/*_test.js.map",
"!dist/**/*_test.d.ts",
102 9 Publishing npm packages with TypeScript
"!dist/**/*_test.d.ts.map"
],
In .gitignore, we have ignored directory dist/ because it contains information that can
be generated automatically. However, here it is explicitly included because most of its
contents have to be in the npm package.
Patterns that start with exclamation marks (!) define which files to exclude. In this case,
we exclude the tests:
"exports": {
// Package exports go here
},
Before we get into details, there are two questions we have to consider:
• Is our package only going to be imported via a bare import or is it going to support
subpath imports?
• The extensionless style has a long tradition. That hasn’t changed much with ESM,
even though it requires filename extensions for local imports.
• Downside of the extensionless style (quoting the Node.js documentation): “With im-
port maps now providing a standard for package resolution in browsers and other
JavaScript runtimes, using the extensionless style can result in bloated import map
definitions. Explicit file extensions can avoid this issue by enabling the import map
to utilize a packages folder mapping to map multiple subpaths where possible in-
stead of a separate map entry per package subpath export. This also mirrors the
requirement of using the full specifier path in relative and absolute import speci-
fiers.”
However, I don’t have strong preferences and may change my mind in the future.
// Bare export
".": "./dist/main.js",
// Extensionless subpaths
"./misc/errors": "./dist/misc/errors.js", // single file
"./misc/*": "./dist/misc/*.js", // subtree
Notes:
• If there aren’t many modules then multiple single-file entries are more self-explana-
tory than one subtree entry.
• By default, .d.ts files must sit next to .js files. But that can be changed via the
types import condition.
For more information on this topic, see section “Package exports: controlling what other
packages see” in “Exploring JavaScript”.
"imports": {
"#root/*": "./*"
},
Package imports are especially helpful when the JavaScript output files are more deeply
nested than the TypeScript input files. In that case we can’t use relative paths to access
files at the top level.
via npm run build. We can get a list of those aliases via npm run (without a script name).
"scripts": {
"\n========== Building ==========": "",
"build": "npm run clean && tsc",
"watch": "tsc --watch",
"clean": "shx rm -rf ./dist/*",
"\n========== Testing ==========": "",
"test": "mocha --enable-source-maps --ui qunit",
"testall": "mocha --enable-source-maps --ui qunit \"./dist/**/*_test.js\"",
"\n========== Publishing ==========": "",
"publishd": "npm publish --dry-run",
"prepublishOnly": "npm run build"
},
Explanations:
• build: I clear directory dist/ before each build. Why? When renaming TypeScript
files, the old output files are not deleted. That is especially problematic with test
files and regularly bites me. Whenever that happens, I can fix things via npm run
build.
• test, testall:
– --enable-source-maps enables source map support in Node.js and therefore
accurate line numbers in stack traces.
– The test runner Mocha supports several testing styles. I prefer --ui qunit
(example).
• publishd: We publish an npm package via npm publish. npm run publishd invokes
the “dry run” version of that command that doesn’t make any changes but provides
helpful feedback – e.g., it shows which files are going to be part of the package.
• prepublishOnly: Before npm publish uploads files to the npm registry, it invokes
this script. By building before publishing, we ensure that no stale files and no old
files are uploaded.
Why the named separators? The make the output of npm run easier to read.
"devDependencies": {
"@types/mocha": "^10.0.6",
"@types/node": "^20.12.12",
"mocha": "^10.4.0",
"shx": "^0.3.4",
"typedoc": "^0.27.6"
},
Explanations:
9.4 Linting npm packages 105
• @types/node: In unit tests, I’m using node:assert for assertions such as assert.
deepEqual(). This dependency provides types for that and other Node modules.
• shx: provides cross-platform versions of Unix shell commands. I’m often using:
shx rm -rf
shx chmod u+x
I also install the following two command line tools locally inside my projects so that they
are guaranteed to be there. The neat thing about npm run is that it adds locally installed
commands to the shell path – which means that they can be used in package scripts as if
they were installed globally.
• mocha and @types/mocha: I still prefer Mocha’s API and CLI user experience but
Node’s built-in test runner has become an interesting alternative.
• typedoc: I’m using TypeDoc to generate API documentation.
"bin": {
"tsconfigurator": "./dist/tsconfigurator.js"
},
We can constrain the Node.js versions with which the bin scripts can be used:
"engines": {
"node": ">=23.6.0"
},
The following package script is useful (invoked from "build", after "tsc"):
"scripts": {
···
"chmod": "shx chmod u+x ./dist/tsconfigurator.js"
}
If a package provides an executable and not a library, we don’t need to emit .d.ts files. If
we use type stripping for .js, we may not need .js.map files either.
• publint: “Lints npm packages to ensure the widest compatibility across environ-
ments, such as Vite, Webpack, Rollup, Node.js, etc.”
• arethetypeswrong: “This project attempts to analyze npm package contents for is-
sues with their TypeScript types, particularly ESM-related module resolution is-
sues.”
• npm-package-json-lint: “Configurable linter for package.json files”
106 9 Publishing npm packages with TypeScript
• vitest-package-exports: “[…] get all exported APIs of a package and prevent unin-
tended breaking changes”. Despite its name, this tool does not require Vitest.
• installed-check: “Verifies that installed modules comply with the requirements [the
Node.js "engines" version range] specified in package.json.”
• Knip: “Finds and fixes unused files, dependencies and exports.” Supports JavaScript
and TypeScript.
• Node Modules Inspector: “Visualize your node_modules, inspect dependencies, and
more.”
• Madge: create a visual graph of module dependencies, find circular dependencies,
and more.
Also useful:
In this chapter, I’ll give a few tips for using TypeScript to write apps.
If you:
Then you can take a look at the “Getting Started” page for Vite, a popular frontend build
tool.
But there are many other build tools for JavaScript. These are some of them:
107
108 10 Creating apps with TypeScript
• By default, Node.js only supports type stripping, but experimental support for non-
erasable features such as JSX and enums is available via --experimental-transform-
types. Type stripping will remain the default.
This chapter describes how to document TypeScript APIs via doc comments (multi-line com-
ments whose contents follow a standard format). We will use the npm-installable com-
mand line tool TypeDoc for this task.
/**
* Adds two numbers
*
* @param {number} x - The first operand
* @param {number} y - The second operand
109
110 11 Documenting TypeScript APIs via doc comments and TypeDoc
The hyphen after parameter names such as x is optional. TypeScript itself supports JSDoc
comments as a way to specify types in plain JavaScript code (more information).
/**
* Adds two numbers
*
* @param x - The first operand
* @param y - The second operand
* @returns The sum of both operands
*/
function add(x: number, y: number): number {
return x + y;
}
11.1.3 TypeDoc
The API documentation generator TypeDoc uses doc comments to generate HTML API
documentation. TSDoc comments are preferred, but JSDoc comments are supported, too.
TypeDoc’s features include:
• Support for Markdown in doc comments, including syntax highlighting for code
blocks.
• Doc comments can refer to code fragments in external files – which can be tested
more easily.
• Web pages generated from files written in Markdown (“external documents”).
"scripts": {
"\n========== TypeDoc ==========": "",
"api": "shx rm -rf docs/api/ && typedoc --out docs/api/ --readme none
--entryPoints src --entryPointStrategy expand --exclude '**/*_test.ts'",
},
The entry for "api" is a single line; I have broken it up so that it can be displayed better.
You can check out the API docs for @rauschma/helpers online (warning: still underdocu-
mented).
File util.ts:
/**
* {@includeCode ./util_test.ts#utilFunc}
*/
function utilFunc(): void {}
Note the hash (#) and the name utilFunc after the path of the file: It refers to a region
inside util_test.ts. A region is a way to mark sequences of lines in a source file via
comments. Regions are also supported by Visual Studio Code where they can be folded
(documentation).
test('utilFunc', () => {
//#region utilFunc
// ...
//#endregion utilFunc
});
In the past, TypeDoc only let us include full files, which meant one file per example – with
test boilerplate showing up in the documentation.
File array.ts:
/**
* Split `arr` into chunks with length `chunkLen` and return them
* in an Array.
* {@includeCode ./array_test.ts#arrayToChunks}
*/
112 11 Documenting TypeScript APIs via doc comments and TypeDoc
File array_test.ts:
// ···
test('arrayToChunks', () => {
//#region arrayToChunks
const arr = ['a', 'b', 'c', 'd'];
assert.deepEqual(
arrayToChunks(arr, 1),
[['a'], ['b'], ['c'], ['d']],
);
//#endregion arrayToChunks
assert.deepEqual(
arrayToChunks(arr, 2),
[['a', 'b'], ['c', 'd']],
);
});
This chapter gives an overview of strategies for migrating code bases from JavaScript to
TypeScript. It also mentions material for further reading.
At first, there are only JavaScript files. Then, one by one, we switch files to TypeScript.
While we do so, our code base keeps being compiled.
{
"compilerOptions": {
···
"allowJs": true
113
114 12 Strategies for migrating to TypeScript
}
}
More information:
This is how we specify static types for plain JavaScript via JSDoc comments:
/**
* @param {number} x - The first operand
* @param {number} y - The second operand
* @returns {number} The sum of both operands
*/
function add(x, y) {
return x + y;
}
More information:
In contrast to compiler options, we can activate linting options on a per-file basis. This
helps when switching from less strict type checking to stricter type checking: We can lint
before making the switch. These are examples of useful rules that the TypeScript linter
typescript-eslint provides:
• We run the TypeScript compiler on the whole code base for the first time.
• The errors produced by the compiler become our initial snapshot.
• As we work on the code base, we compare new error output with the previous snap-
shot:
– Sometimes existing errors disappear. Then we can create a new snapshot.
– Sometimes new errors appear. Then we either have to fix these errors or create
a new snapshot.
More information:
• Start your migration with experiments: Play with your code base and try out various
strategies before committing to one of them.
• Then lay out a clear plan for going forward. Discuss prioritization with your team:
– Sometimes finishing the migration quickly may take priority.
– Sometimes the code remaining fully functional during the migration may be
more important.
– And so on…
Further reading:
Basic types
117
Chapter 13
What are types in TypeScript? This chapter describes two perspectives that help with un-
derstanding them. Both are useful; they complement each other.
119
120 13 What is a type in TypeScript? Two perspectives
• The source code has locations and each location has a static type. In a TypeScript-
aware editor, we can see the static type of a location if we hover above it with the
cursor.
• Types are defined via their relationships with other types.
The most important type relationship is assignment compatibility: Can a location whose type
is Src be assigned to a location whose type is Trg? The answer is yes if:
1. Parameter arg having type MyType means that we can only pass a value to myFunc(
) whose type is assignable to MyType.
2. UnionType is defined by the relationships it has with other types. Above, we have
seen two rules for union types.
• The static type Src of an actual parameter (e.g., provided via a function call)
• The static type Trg of the corresponding formal parameter (e.g., specified as part of
a function definition)
The type system needs to check if Src is assignable to Trg. Two approaches for this check
are (roughly):
• In a nominal or nominative type system, two static types are equal if they have the
same identity (“name”). Src is only assignable to Trg if they are equal or if a re-
lationship between them was specified explicitly – e.g., an inheritance relationship
(extends).
– Languages with nominal type systems include C++, Java and C#.
• In a structural type system, a type Src is assignable to a type Trg if Trg has a structure
that can receive what’s in Src — e.g.: For each field Src.F, there must be a field Trg.
F such that Src.F is assignable to Trg.F.
13.5 Further reading 121
The following code produces a type error in the last line with a nominal type system, but
is legal with TypeScript’s structural type system because class A and class B have the same
structure:
class A {
typeName = 'A';
}
class B {
typeName = 'B';
}
const someVariable: A = new B();
TypeScript’s interfaces also work structurally – they don’t have to be implemented in order
to match:
interface HasTypeName {
typeName: string;
}
const hasTypeName: HasTypeName = new A(); // OK
In TypeScript, any and unknown are types that contain all values. In this chapter, we examine
what they are and what they can be used for.
The top type […] is the universal type, sometimes called the universal supertype
as all other types in any given type system are subtypes […]. In most cases it is
the type which contains every possible [value] in the type system of interest.
That is, when viewing types as sets of values (for more information on what types are,
see “What is a type in TypeScript? Two perspectives” (§13)), any and unknown are sets that
contain all values.
TypeScript also has the bottom type never, which is the empty set and explained in “The
bottom type never” (§15).
123
124 14 The top types any and unknown
// Normally only allowed for Arrays and types with index signatures
value[123];
}
storageLocation = null;
storageLocation = true;
storageLocation = {};
With any we lose any protection that is normally given to us by TypeScript’s static type
system. Therefore, it should only be used as a last resort, if we can’t use more specific
types or unknown.
JSON.parse() was added to TypeScript before the type unknown existed. Otherwise, its
return type would probably be unknown.
interface StringConstructor {
(value?: any): string; // call signature
// ···
}
14.3 The top type unknown 125
TypeScript does not complain about us using the type any explicitly:
storageLocation = null;
storageLocation = true;
storageLocation = {};
Therefore, if we have a value of type unknown, we must narrow that type before we can do
anything with the value – e.g., via:
• Type assertions:
value.toFixed(2);
// Type assertion:
(value as number).toFixed(2); // OK
}
• Equality:
• Type guards:
• Assertion functions:
assertType<RegExp>(value);
value.test('abc'); // OK
}
In this chapter, we look at the special TypeScript type never which, roughly, is the type of
things that never happen. As we’ll see, it has a surprising number of applications.
• A top type T includes all values and all types are subtypes of T.
• A bottom type B is the empty set and a subtype of all types.
In TypeScript:
• any and unknown are top types and explained in “The top types any and unknown”
(§14).
• never is a bottom type.
127
128 15 The bottom type never
type _ = [
Assert<Equal<
keyof {a: 1, b: 2},
'a' | 'b' // set of types
>>,
Assert<Equal<
keyof {},
never // empty set
>>,
];
Similarly, if we use the type operator & to intersect two types that have no elements in
common, we get the empty set:
type _ = Assert<Equal<
boolean & symbol,
never
>>;
If we use the type operator | to compute the union of a type T and never then the result is
T:
type _ = Assert<Equal<
'a' | 'b' | never,
'a' | 'b'
>>;
In line A, x can still have the value false and true. After we return if x has the value true,
it can still have the value false (line B). After we return if x has the value false, there are
no more values this variable can have, which is why it has the type never (line C).
This behavior is especially useful for enums and unions used like enums because it enables
exhaustiveness checks (checking if we have exhaustively handled all cases):
The following pattern works well for JavaScript because it checks at runtime if color has
an unexpected value:
How can we support this pattern at the type level so that we get a warning if we acciden-
tally don’t consider all member of the enum Color? (The return type string also keeps us
130 15 The bottom type never
safe but with the technique we are about to see, we even get protection if there is no return
time. Additionally, we are also protected from illegal values at runtime.)
Let’s first examine how the inferred value of color changes as we add cases:
Once again, the type records what values color still can have.
The following implementation of the class UnexpectedValueError requires that the type of
its actual argument be never:
Now we get a compile-time warning if we forget a case because we have not eliminated
all values that color can have:
assertType<Color.Green>(color);
// @ts-expect-error: Argument of type 'Color.Green' is not
// assignable to parameter of type 'never'.
throw new UnexpectedValueError(color);
}
}
If we call such functions, TypeScript knows that execution ends and adjusts inferred types
accordingly. For more information, see “Return type never: functions that don’t return”.
132 15 The bottom type never
• Blog post “The never type and error handling in TypeScript” by Stefan Baumgartner
Chapter 16
Symbols in TypeScript
In this chapter, we examine how TypeScript handles JavaScript symbols at the type level.
If you want to refresh your knowledge of JavaScript symbols, you can check out chapter
“Symbols” of “Exploring JavaScript”.
For example:
133
134 16 Symbols in TypeScript
symbol is the type of all symbols. typeof SYM2 is the type of one specific symbol. There is
no way for us to create another value that matches typeof SYM2:
f(SYM2); // OK
// @ts-expect-error: Argument of type 'symbol' is not assignable to
// parameter of type 'unique symbol'.
f(Symbol('SYM2')); // new, different value!
Note the type unique symbol in the error message. We’ll get to what it is soon.
Out inability to create a new symbol that is equal to the original SYM2 is a JavaScript phe-
nomenon:
> {} === {}
false
If we assign a variable SYM with the type typeof SYM to another variable X, then the type of
the latter is broadened to symbol – even when we declare it with const.
Related GitHub issue: “unique symbol lost on assignment to const despite type assertion”
unique symbol can be used in const variable declaration and static readonly properties.
If we want to express uniqueness elsewhere, we have to use typeof S – as we have done
previously. Given that unique symbol is basically another way of expressing typeof S, it’s
not very useful
There is one more location where we can use unique symbol – as the type of a read-only
property. That is done to declare the types of well-known symbols such as Symbol.iterator
(file lib.es2015.iterable.d.ts):
interface SymbolConstructor {
readonly iterator: unique symbol;
}
Why is the name of the interface SymbolConstructor? That’s due to how symbols are set
up in file lib.es2015.symbol.d.ts:
interface SymbolConstructor {
readonly prototype: Symbol;
(description?: string | number): symbol;
for(key: string): symbol;
keyFor(sym: symbol): string | undefined;
}
type Obj = {
readonly sym: unique symbol,
};
How does a symbol-based union type compare to a string-based union type such as the
one below?
To make it easier to compare ActSym with ActStr, let’s define the latter in a more compli-
cated way (which we normally wouldn’t do):
• Pro: No need to import constants, we can simply mention the strings (see last line
above).
• Con: The union values are not type-safe. Strings are compared by value (not by
identity), which is why a value can be mistaken to be a member of ActStr when it
actually isn’t. That kind of mistake cannot happen with symbol-based type unions.
16.3 Symbols as special values 137
type StreamValue =
| null // end of file
| string
;
For more information on this topic, see “Adding special values to types” (§17).
const Activation = {
__proto__: null,
Active, // (A)
Inactive, // (B)
} as const;
Why the intermediate step of declaring the variables Active and Inactive in the first two
lines? Why don’t we create the symbols in line A and line B?
If we do that then:
• Activation.Active will have the type symbol, not the type typeof Active.
• Activation.Inactive will have the type symbol, not the type typeof Inactive.
One way of understanding types is as sets of values. Sometimes there are two levels of
values:
In this chapter, we examine how we can add special values to base-level types.
interface LineStream {
getNextLine(): string;
}
At the moment, .getNextLine() only handles text lines, but not ends of files (EOFs). How
could we add support for EOF?
Possibilities include:
139
140 17 Adding special values to types
The next two subsections describe two ways in which we can introduce sentinel values.
interface LineStream {
getNextLine(): StreamValue;
}
Now, whenever we are using the value returned by .getNextLine(), TypeScript forces us
to consider both possibilities: strings and null – for example:
In line A, we can’t use the string method .startsWith() because line might be null. We
can fix this as follows:
Now, when execution reaches line A, we can be sure that line is not null.
17.2 Adding special values out of band 141
Why do we need typeof and can’t use EOF directly? That’s because EOF is a value, not a
type. The type operator typeof converts EOF to a type.
assert.equal(
parseNumber('123'), 123
);
assert.equal(
parseNumber('hello'), couldNotParseNumber
);
interface ValueStream<T> {
getNextValue(): T;
}
Whatever value we pick for EOF, there is a risk of someone creating an ValueStream<typeof
EOF> and adding that value to the stream.
142 17 Adding special values to types
The solution is to keep normal values and special values separate, so that they can’t be
mixed up. Special values existing separately is called out of band (think different channel).
Example: ValueStreamValue
interface NormalValue<T> {
type: 'normal'; // string literal type
data: T;
}
interface Eof {
type: 'eof'; // string literal type
}
type ValueStreamValue<T> = Eof | NormalValue<T>;
interface ValueStream<T> {
getNextValue(): ValueStreamValue<T>;
}
Initially, the type of value is ValueStreamValue<T> (line A). Then we exclude the value
'eof' for the discriminant .type and its type is narrowed to NormalValue<T> (line B). That’s
why we can access property .data in line C.
17.2 Adding special values out of band 143
Example: IteratorResult
When deciding how to implement iterators, TC39 didn’t want to use a fixed sentinel value.
Otherwise, code would break if that value appeared in an iterable. One solution would
have been to pick a sentinel value when starting an iteration. TC39 instead opted for a
discriminated union with the common property .done:
interface IteratorYieldResult<TYield> {
done?: false; // boolean literal type
value: TYield;
}
interface IteratorReturnResult<TReturn> {
done: true; // boolean literal type
value: TReturn;
}
interface A {
one: number;
two: number;
}
interface B {
three: number;
four: number;
}
type Union = A | B;
Another possibility is to distinguish the member types via typeof and/or instance checks:
145
Chapter 18
Typing objects
147
148 18 Typing objects
In this chapter, we will explore how objects and properties are typed statically in Type-
Script.
• Fixed-layout object: Used this way, an object works like a record in a database. It
has a fixed number of properties, whose keys are known at development time. Their
values generally have different types.
• Dictionary object: Used this way, an object works like a lookup table or a map. It has
a variable number of properties, whose keys are not known at development time.
All of their values have the same type.
Note that the two ways can also be mixed: Some objects are both fixed-layout objects and
dictionary objects.
The most common ways of typing these two kinds of objects are:
type FixedLayoutObjectType = {
product: string,
quantity: number,
};
type DictionaryObjectType = Record<string, number>;
18.2 Members of object literal types 149
Next, we’ll look at fixed-layout object types in more detail before coming back to dictionary
object types.
logPoint(myPoint); // Works!
For more information on this topic, see “Nominal type systems vs. structural type sys-
tems” (§13.4).
type ExampleObjectType = {
// Property signature
myProperty: boolean,
// Method signature
myMethod(str: string): number,
// Index signature
[key: string]: any,
// Call signature
(num: number): string,
// Construct signature
new(str: string): ExampleInstanceType,
};
myProperty: boolean;
• Method signatures define methods and are described in the next subsection.
Note: The names of parameters (in this case: str) help with documenting how
things work but have no other purpose.
• Index signatures are needed to describe Arrays or objects that are used as dictionar-
ies. They are described later in this chapter.
• Call signatures enable object literal types to describe functions. See “Interfaces with
call signatures”.
• Construct signatures enable object literal types to describe classes and constructor
functions. See “Object type literals with construct signatures” (§23.2.3).
type HasMethodDef = {
simpleMethod(flag: boolean): void,
};
type HasFuncProp = {
simpleMethod: (flag: boolean) => void,
};
type _ = Assert<Equal<
HasMethodDef,
HasFuncProp
>>;
const objWithMethod = {
simpleMethod(flag: boolean): void {},
};
assertType<HasMethodDef>(objWithMethod);
assertType<HasFuncProp>(objWithMethod);
assertType<HasFuncProp>(objWithOrdinaryFunction);
This rarely matters in practice, but as an aside: Just like in JavaScript, we can use unquoted
numbers as keys. Unlike JavaScript, those keys are considered to be number literal types:
type _ = Assert<Equal<
keyof {0: 'a', 1: 'b'},
0 | 1
>>;
assert.deepEqual(
Object.keys({0: 'a', 1: 'b'}),
[ '0', '1' ]
);
Computed property keys are a JavaScript feature. There is a similar feature at the type
level:
type ExampleObjectType = {
// Property signature with computed key
[Symbol.toStringTag]: string,
Unexpectedly, computed property keys are values, not types. TypeScript internally applies
typeof to create the type:
152 18 Typing objects
type _ = Assert<Equal<
{ ['hello']: string },
{ hello: string }
>>;
What kind of value is allowed as a computed property key? Its type must be:
If we put a question mark (?) after the name of a property, that property is optional. The
same syntax is used to mark parameters of functions, methods, and constructors as op-
tional. In the following example, property .middle is optional:
type Name = {
first: string;
middle?: string;
last: string;
};
type Obj = {
prop1?: string;
prop2: undefined | string;
};
Types such as undefined | string and null | string are useful if we want to make omis-
sions explicit. When people see such an explicitly omitted property, they know that it
exists but was switched off.
type Obj = {
prop1?: string;
prop2: undefined | string;
};
Read-only properties
type MyObj = {
readonly prop: number;
};
console.log(obj.prop); // OK
type Point = {
x: number,
y: number,
};
There are two ways (among others) in which this object literal type could be interpreted:
154 18 Typing objects
• Closed interpretation: It could describe all objects that have exactly the properties .x
and .y with the specified types. On other words: Those objects must not have excess
properties (more than the required properties).
• Open interpretation: It could describe all objects that have at least the properties .x
and .y. In other words: Excess properties are allowed.
TypeScript uses both interpretations. To explore how that works, we will use the following
function:
const obj = { x: 1, y: 2, z: 3 };
computeDistance(obj); // OK
However, if we use object literals directly, then excess properties are forbidden:
computeDistance({x: 1, y: 2}); // OK
type Person = {
first: string,
middle?: string,
last: string,
};
function computeFullName(person: Person) { /*...*/ }
Property .middle is optional and can be omitted. To TypeScript, mistyping its name looks
like omitting it and providing an excess property. However, it still catches the typo because
excess properties are not allowed in this case:
18.3.2 Why are excess properties allowed if an object comes from some-
where else?
The idea is that if an object comes from somewhere else, we can assume that it has already
been vetted and will not have any typos. Then we can afford to be less careful.
If typos are not an issue, our goal should be maximizing flexibility. Consider the following
function:
18.3 Excess property checks: When are extra properties allowed? 155
type HasYear = {
year: number,
};
Without allowing excess properties for values that are passed to getAge(), the usefulness
of this function would be quite limited.
type WithoutProperties = {
[key: string]: never,
};
type Point = {
x: number,
y: number,
};
const obj = { x: 1, y: 2, z: 3 };
computeDistance1(obj);
computeDistance1({ x: 1, y: 2, z: 3 } as Point); // OK
computeDistance3({ x: 1, y: 2, z: 3 }); // OK
We used an intersection type (& operator) to define PointEtc. For more information, see
“Intersections of object types” (§20.1).
We’ll continue with two examples where TypeScript not allowing excess properties, is a
problem.
In this example, we implement a factory for objects of type Incrementor and would like to
return a subtype, but TypeScript doesn’t allow the extra property .counter:
type Incrementor = {
inc(): number,
};
function createIncrementor(): Incrementor {
return {
// @ts-expect-error: Object literal may only specify known properties, and
// 'counter' does not exist in type 'Incrementor'.
counter: 0,
inc() {
// @ts-expect-error: Property 'counter' does not exist on type
// 'Incrementor'.
return this.counter++;
},
};
}
18.3 Excess property checks: When are extra properties allowed? 157
Alas, even with a type assertion, there is still one type error:
What does work is as any but then the type of the returned object is any and, e.g. inside .
inc(), TypeScript doesn’t check if properties of this really exist.
The following comparison function can be used to sort objects that have the property .
dateStr:
function compareDateStrings(
a: {dateStr: string}, b: {dateStr: string}) {
if (a.dateStr < b.dateStr) {
return +1;
} else if (a.dateStr > b.dateStr) {
return -1;
} else {
return 0;
}
}
For example in unit tests, we may want to invoke this function directly with object literals.
TypeScript doesn’t let us do this and we need to use one of the workarounds.
158 18 Typing objects
type MyType = {
toString(): string, // inherited property
prop: number, // own property
};
const obj: MyType = { // OK
prop: 123,
};
The downside of this approach is that some phenomena in JavaScript can’t be described
via TypeScript’s type system. The upside is that the type system is simpler.
// Interface
interface ObjType2 {
a: boolean;
b: number;
18.5 Interfaces vs. object literal types 159
c: string;
}
• In both cases, either semicolons or commas can be used as separators. I prefer com-
mas for object literal types and semicolons for interfaces because that reflects what
JavaScript looks like (object literals and classes).
• Trailing separators are allowed and optional.
Both ways of defining an object type are more or less equivalent now. We’ll dive into the
(minor) differences next.
interface PersonInterface {
first: string;
}
interface PersonInterface {
last: string;
}
const jane: PersonInterface = {
first: 'Jane',
last: 'Doe',
};
This is called declaration merging and can be used to combine types from multiple sources
– e.g., as long as Array.fromAsync() is a new method, it is not part of the core library
declaration file, but provided via lib.esnext.array.d.ts – which adds it as an increment
to ArrayConstructor (the type of Array as a class value):
160 18 Typing objects
interface ArrayConstructor {
fromAsync<T>(···): Promise<T[]>;
}
type Point = {
x: number,
y: number,
};
type PointCopy1 = {
[Key in keyof Point]: Point[Key] // (A)
};
As an option, we can end line A with a semicolon. Alas, a comma is not allowed.
For more information on this topic, see “Mapped types {[K in U]: X}”.
interface AddsStrings {
add(str: string): this;
};
18.5.5 Only interfaces support extends – but type intersection (&) is sim-
ilar
An interface B can extend another interface A and is then interpreted as an increment of A:
interface A {
propA: number;
}
interface B extends A {
propB: number;
}
type _ = Assert<Equal<
B,
18.5 Interfaces vs. object literal types 161
{
propA: number,
propB: number,
}
>>;
Object literal types don’t support extend but an intersection type & has a similar effect:
type A = {
propA: number,
};
type B = {
propB: number,
} & A;
type _ = Assert<Equal<
B,
{
propA: number,
propB: number,
}
>>;
Intersections of object types are described in more detail in another chapter. Here, we’ll
explore how exactly they differ from extends.
Conflicts
If there is a conflict between an extending interface and an extended interface then that’s
an error:
interface A {
prop: string;
}
// @ts-expect-error: Interface 'B' incorrectly extends interface 'A'.
// Types of property 'prop' are incompatible.
interface B extends A {
prop: number;
}
In contrast, intersection types don’t complain about conflicts, but they may result in never
in some locations:
type A = {
prop: string,
};
type B = {
prop: number,
} & A;
type _ = Assert<Equal<
B,
{
162 18 Typing objects
• The overriding method can return more specific values – e.g. invokers of the over-
ridden method that expect an Object won’t mind if the overriding method returns
a RegExp.
• The overriding method can expect less specific parameters – e.g. invokers of the
overridden method that pass an argument of type string won’t mind if the over-
riding method accepts string | number.
interface A {
m(x: string): Object;
}
interface B extends A {
m(x: string | number): RegExp;
}
type _ = Assert<Equal<
B,
{
m(x: string | number): RegExp,
}
>>;
function f(x: B) {
assertType<RegExp>(x.m('abc'));
}
We can see that the overriding method “wins” and completely replaces the overridden
method in B. In contrast, both methods exist in parallel in an intersection type:
type A = {
m(x: string): Object,
};
type B = {
m(x: string | number): RegExp,
};
type _ = [
Assert<Equal<
A & B,
{
m: ((x: string) => Object) & ((x: string | number) => RegExp),
}
18.6 Forbidding properties via never 163
>>,
Assert<Equal<
B & A,
{
m: ((x: string | number) => RegExp) & ((x: string) => Object),
}
>>,
];
When it comes to the return type (line A and line B), the earlier member of the intersection
wins. That’s why B & A (B1) is more similar to B extends A, even though A & B (B2) looks
nicer:
type B1 = {
prop: number,
} & A;
type B2 = A & {
prop: number,
};
Which one to use depends on the context. If inheritance is involved then an interface and
extends is usually the better choice due to their support of overriding.
In contrast, the type {} is assignable from all objects and not a type for empty objects:
One option is to use an index signature (line A) to express that TranslationDict is for
objects that map string keys to string values (another option is Record – which we’ll get to
later):
type TranslationDict = {
[key: string]: string, // (A)
};
const dict = {
'yes': 'sí',
'no': 'no',
'maybe': 'tal vez',
};
assert.equal(
18.7 Index signatures: objects as dictionaries 165
translate(dict, 'maybe'),
'tal vez');
The name key doesn’t matter – it can be any identifier and is ignored (but can’t be omitted).
• string
• number
• symbol
• A template string literal with an infinite primitive type – e.g.: `${bigint}`
• A union of any of the previous types
type IndexSignature1 = {
[key: string]: boolean,
};
// Template string literal with infinite primitive type
type IndexSignature2 = {
[key: `${bigint}`]: string,
};
// Union of previous types
type IndexSignature3 = {
[key: string | `${bigint}`]: string,
};
type StringAndNumberKeys = {
[key: string]: Object,
166 18 Typing objects
The following code demonstrates the effects of using strings and numbers as property
keys:
type T1 = {
[key: string]: boolean,
type T2 = {
[key: string]: number,
myProp: number,
};
type T3 = {
[key: string]: () => string,
myMethod(): string,
}
18.8 Record<K, V> for dictionary objects 167
If you are curious how Record is defined: “Record is a mapped type”. This knowledge can
help with remembering how it handles finite and infinite key types.
Record supports unions of literal types as key types; index signatures don’t. More on that
next.
• object with a lowercase “o” is the type of all non-primitive values. It’s loosely re-
lated to the value 'object' returned by the JavaScript operator typeof.
• Object with an uppercase “O” is the type of the instances of class Object:
But it also accepts primitive values (except for undefined and null):
Note that non-nullish primitive values inherit the methods of Object.prototype via
their wrapper types.
• {} accepts all non-nullish values. Its only difference with Object is that it doesn’t
mind if a property conflicts with Object.prototype properties:
That means:
> Object.prototype.isPrototypeOf(obj1)
true
On the other hand, we can also create objects that don’t have Object.prototype in their
prototype chains. For example, the following object does not have any prototype at all:
• A constructor function C.
170 18 Typing objects
• Type Object specifies the properties of instances of Object, including the properties
inherited from Object.prototype.
• Type ObjectConstructor specifies the properties of class Object (an object with prop-
erties).
interface ObjectConstructor {
/** Invocation via `new` */
new(value?: any): Object;
/** Invocation via function calls */
(value?: any): any;
Observations:
• We have both a variable whose name is Object (line D) and a type whose name is
Object (line A).
• Object.prototype also has the type Object (line C). Given that any instance of Object
inherits all of its properties, that makes sense.
• It’s interesting that, in line B, .valueOf() has the return type Object and is supposed
to return primitive values.
/**
* Exclude null and undefined from T
*/
type NonNullable<T> = T & {};
type _ = [
Assert<Equal<
NonNullable<undefined | string>,
string
>>,
Assert<Equal<
NonNullable<null | string>,
string
>>,
Assert<Equal<
NonNullable<string>,
string
>>,
];
The result of NonNullable<T> is a type that is the intersection of T and all non-nullish values.
In principle, the return type of Object.create() could (and probably should) be object
172 18 Typing objects
or a computed type. However, for historic reasons, it is any. That allows us to add and
change properties of the result.
The last two table rows don’t really make sense for Record – which is why there is an “N/
A” in its cells.
type _ = [
Assert<Not<Assignable<
object, undefined
>>>,
Assert<Not<Assignable<
Object, undefined
>>>,
Assert<Not<Assignable<
{}, undefined
>>>,
Assert<Not<Assignable<
Record<keyof any, any>, undefined
>>>,
];
type _ = [
Assert<Not<Assignable<
object, 123
>>>,
Assert<Assignable<
Object, 123
>>,
Assert<Assignable<
{}, 123
>>,
Assert<Not<Assignable<
Record<keyof any, any>, 123
>>>,
];
18.11 Sources of this chapter 173
Has .toString():
type _ = [
Assert<Assignable<
{ toString(): string }, object
>>,
Assert<Assignable<
{ toString(): string }, Object
>>,
Assert<Assignable<
{ toString(): string }, {}
>>,
];
type _ = [
Assert<Assignable<
object, { toString(): number }
>>,
Assert<Not<Assignable<
Object, { toString(): number }
>>>,
Assert<Assignable<
{}, { toString(): number }
>>,
];
In this chapter, we explore what unions of object types can be used for in TypeScript.
175
176 19 Unions of object types
type Triangle = {
corner1: Point,
corner2: Point,
corner3: Point,
};
type Rectangle = {
corner1: Point,
corner2: Point,
};
type Circle = {
center: Point,
radius: number,
};
type Point = {
x: number,
y: number,
};
A function readFile() for VirtualFileSystem would work as follows (line A and line B):
'Hello!'
);
assert.equal(
readFile(vfs, '/tmp/echo.txt'), // (B)
'/tmp/echo.txt'
);
We have to narrow its type to one of the elements of this union type before we can access
properties. And TypeScript lets us do that via the in operator (line A, line B, line C).
For more information on this technique and a longer and better implementation of Unex
pectedValueError, see “Use case for never: exhaustiveness checks at compile time” (§15.4).
type FileEntry =
| {
kind: 'FileEntryData',
data: string,
}
| {
kind: 'FileEntryGenerator',
generator: (path: string) => string,
}
| {
kind: 'FileEntryFile',
path: string,
}
;
type VirtualFileSystem = Map<string, FileEntry>;
The property of a discriminated union that has the type information is called a discriminant
or a type tag. The discriminant of FileEntry is .kind. Other common names are .tag, .key
and .type.
On one hand, FileEntry is more verbose now. On the other hand, discriminants give us
several benefits – as we’ll see soon.
This brings us to a first advantage of discriminated unions: We can use switch statements.
And it’s immediately clear that .kind distinguishes the type union elements – we don’t
have to look for property names that are unique to elements.
Note that narrowing works as it did before: Once we have checked .kind, we can access
all relevant properties.
Another benefit is that, if the union elements are inlined (and not defined externally via
types with names) then we can still see what each element does:
type Shape =
| {
tag: 'Triangle',
corner1: Point,
corner2: Point,
corner3: Point,
}
| {
tag: 'Rectangle',
corner1: Point,
corner2: Point,
}
| {
tag: 'Circle',
center: Point,
radius: number,
}
;
Discriminated unions work even if all normal properties of union elements are the same:
type Temperature =
| {
type: 'TemperatureCelsius',
value: number,
}
| {
180 19 Unions of object types
type: 'TemperatureFahrenheit',
value: number,
}
;
The following type definition is terse; but can you tell how it works?
type OutputPathDef =
| null // same as input path
| '' // stem of output path
| string // output path with different extension
type OutputPathDef =
| { key: 'sameAsInputPath' }
| { key: 'inputPathStem' }
| { key: 'inputPathStemPlusExt', ext: string }
;
type Content =
| {
19.2 Deriving types from discriminated unions 181
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
| {
kind: 'video',
width: number,
height: number,
runningTimeInSeconds: number,
}
;
type _ = Assert<Equal<
ContentKind,
'text' | 'image' | 'video'
>>;
Because indexed access types are distributive over unions, T['kind'] is applied to each
element of Content and the result is a union of string literal types.
If the map should not be exhaustive, we can use the utility type Partial:
type ExtractSubtype<
Union extends {kind: string},
SubKinds extends GetKind<Union> // (A)
> =
Union extends {kind: SubKinds} ? Union : never // (B)
;
• Line B: If property .kind of a union element has a type that is assignable to SubKinds
then we keep the element. If not then we omit it (by returning never).
• The extends in line A ensures that we don’t make a typo when we extract: Our
discriminant values SubKinds must be a subset of GetKind<Union> (see earlier sub-
section).
type _ = Assert<Equal<
ExtractSubtype<Content, 'text' | 'image'>,
| {
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
>>;
As an alternative to our own ExtractSubtype, we can also use the built-in utility type
Extract:
type _ = Assert<Equal<
Extract<Content, {kind: 'text' | 'image'}>,
| {
kind: 'text',
charCount: number,
}
| {
kind: 'image',
width: number,
height: number,
}
>>;
Extract returns all elements of the union Content that are assignable to the following type:
19.3 Class hierarchies vs. discriminated unions 183
1 + 2 + 3
• A number value
• The addition of two syntax trees
The operation evaluate handles the two cases “number value” and “addition” in the cor-
responding classes – via polymorphism. Here it is in action:
new NumberValue(2),
new NumberValue(3),
),
);
assert.equal(
syntaxTree.evaluate(), 6
);
type SyntaxTree =
| {
kind: 'NumberValue';
numberValue: number;
}
| {
kind: 'Addition';
operand1: SyntaxTree;
operand2: SyntaxTree;
}
;
The operation evaluate handles the two cases “number value” and “addition” in a single
location, via switch. Here it is in action:
kind: 'NumberValue',
numberValue: 2,
},
operand2: {
kind: 'NumberValue',
numberValue: 3,
},
}
};
assert.equal(
evaluate(syntaxTree), 6
);
We don’t need the type annotation in line A, but it helps ensure that the data has the correct
structure. If we don’t do it here, we’ll find out about problems later.
• With classes, we have to modify each class if we want to add a new operation. How-
ever, adding a new type does not require any changes to existing code.
}
}
Why would we want to do that? We can define and inherit methods for the elements of
the union.
The abstract class AbstractColor is only needed if we want to share methods between the
union classes.
Chapter 20
In this chapter, we explore what intersections of object types can be used for in TypeScript.
187
188 20 Intersections of object types
interface Person {
name: string;
}
interface Employe extends Person {
company: string;
}
type Person = {
name: string,
};
type Employee =
& Person
& {
company: string,
}
;
One caveat is that only extends supports overriding. For more information, see $type.
/**
* Exclude null and undefined from T
*/
type NonNullable<T> = T & {};
type _ = [
Assert<Equal<
NonNullable<undefined | string>,
string
>>,
Assert<Equal<
NonNullable<null | string>,
string
>>,
Assert<Equal<
NonNullable<string>, // (A)
string
>>,
];
The result of NonNullable<T> is a type that is the intersection of T and all non-nullish values.
It’s interesting that string & {} is string (line A).
20.1 Intersections of object types 189
type WithKey = {
key: string,
};
function addKey<Obj extends object>(obj: Obj, key: string)
: Obj & WithKey
{
const objWithKey = obj as (Obj & WithKey);
objWithKey.key = key;
return objWithKey;
}
const paris = {
city: 'Paris',
};
191
192 21 Class definitions in TypeScript
• First, we take a quick look at the features of class definitions in plain JavaScript.
• Then we explore what additions TypeScript brings to the table.
assert.equal(MyClass2.staticPublicField, 1);
assert.equal(MyClass2.staticPublicMethod(), 2);
#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);
}
}
class MyClass4 {
#name = 'Rumpelstiltskin';
class MyClass7 {
[publicInstanceFieldKey] = 1;
[publicPrototypeMethodKey]() {
return 2;
}
}
Comments:
• The main use case for this feature is symbols such as Symbol.iterator. But any
expression can be used inside the square brackets.
• We can compute the names of fields, methods, and accessors.
• We cannot compute the names of private members (which are always fixed).
Methods (columns: Level, Accessor, Async, Generator, Private, Code – without body):
21.1 Cheat sheet: classes in plain JavaScript 195
class ClassA {
static staticMthdA() {}
constructor(instPropA) {
this.instPropA = instPropA;
}
prototypeMthdA() {}
}
class ClassB extends ClassA {
static staticMthdB() {}
constructor(instPropA, instPropB) {
super(instPropA);
this.instPropB = instPropB;
}
196 21 Class definitions in TypeScript
prototypeMthdB() {}
}
const instB = new ClassB(0, 1);
Figure 21.1 shows what the prototype chains look like that are created by ClassA and
ClassB.
… …
ClassA ClassA.prototype
__proto__ __proto__
prototype protoMthdA ƒ
staticMthdA ƒ constructor
ClassB ClassB.prototype
__proto__ __proto__
prototype protoMthdB ƒ
staticMthdB ƒ constructor
instB
__proto__
instPropA 0
instPropB 1
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).
• Private properties
• Private fields
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):
assert.equal(
john.sayHello(), 'Hello John!'
);
However, private doesn’t change anything at runtime. There, property .name is indistin-
guishable 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}!`;
}
}
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:
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),
[]
);
class PersonPrivateField {
#name;
constructor(name) {
this.#name = name;
}
sayHello() {
return `Hello ${this.#name}!`;
}
}
– We can’t reuse the names of private properties in subclasses (because the prop-
erties aren’t private at runtime).
– No encapsulation at runtime.
• Upsides of private properties:
– Clients can circumvent the encapsulation and access private properties. This
can be useful if someone needs to work around a bug. In other words: Data
being completely encapsulated has pros and cons.
– Some JavaScript helper functions, e.g. for cloning or for serialization to JSON,
don’t work with private fields.
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}!`;
}
}
200 21 Class definitions in TypeScript
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 fac-
tory 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.
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
class Point {
x = 0;
y = 0;
// No constructor needed
}
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:
constructor(props: CompilerErrorProps) {
Object.assign(this, props); // (B)
}
}
Notes:
class C {
str;
constructor(str: string) {
this.str = str; // (A)
}
}
If we use the modifier public for a constructor parameter prop, then TypeScript does two
things for us:
This is an example:
21.6 Abstract classes 203
class Point {
constructor(public x: number, public y: number) {
}
}
If we use private or protected instead of public, then the corresponding instance prop-
erties are private or protected.
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
• An abstract class can’t be instantiated. Only its subclasses can – if they are not ab-
stract, themselves.
• An abstract method has no implementation, only a type signature. Each concrete
subclass must have a concrete method with the same name and a compatible type
signature.
– If a class has any abstract methods, it must be abstract, too.
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:
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');
}
}
• An abstract class can be seen as an interface where some members already have
implementations.
• While a class can implement multiple interfaces, it can only extend at most one ab-
stract class.
• “Abstractness” only exists at compile time. At runtime, abstract classes are normal
classes and abstract methods don’t exist (due to them only providing compile-time
information).
• Abstract classes can be seen as templates where each abstract method is a blank that
has to be filled in (implemented) by subclasses.
21.7 Keyword override for methods 205
class A {
m(): void {}
}
class B extends A {
// `override` is required
override m(): void {} // (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 {}
}
class Counter {
count = 0;
inc(): void {
this.count++;
}
}
type Counter = {
count: number,
};
function createCounter(): Counter {
return {
count: 0,
};
}
function inc(counter: Counter): void {
counter.count++;
}
• They work better if objects are cloned: Library functions for cloning can’t handle
private fields and structuredClone() does not preserve the class of an instance.
• They work better if objects are moved between realms: Each realm has its own ver-
sion of a given class and that makes moving class instances problematic.
• With object types, deserialization is easier because we can immediate work with the
result of JSON.parse() (potentially after validating the type via Zod).
• Things get more complicated if not all data can be easily serialized and deserialized
– e.g. if a property contains a Map. Then classes have one benefit: We can customize
serialization by implementing the method .toJSON().
21.8 Classes vs. object types 207
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-ori-
ented coding style (classes) and a functional coding style (discriminated unions). For more
information, see “Class hierarchies vs. discriminated unions” (§19.3).
208 21 Class definitions in TypeScript
Chapter 22
Class-related types
209
210 22 Class-related types
// Static method
const myCounter = Counter.createZero();
assert.ok(myCounter instanceof Counter);
assert.equal(myCounter.value, 0);
// Instance method
myCounter.increment();
assert.equal(myCounter.value, 1);
Object Object.prototype
··· ···
Counter Counter.prototype
__proto__ __proto__
prototype increment
create ··· constructor
myCounter
__proto__
value 0
Figure 22.1: Objects created by class Counter. Left-hand side: the class and its superclass
Object. Right-hand side: The instance myCounter, the prototype properties of Counter, and
the prototype methods of the superclass Object.
The diagram in figure 22.1 shows the runtime structure of class Counter. There are two
prototype chains of objects in this diagram:
• Class (left-hand side): The static prototype chain consists of the objects that make
up class Counter. The prototype object of class Counter is its superclass, Object.
• Instance (right-hand side): The instance prototype chain consists of the objects that
make up the instance myCounter. The chain starts with the instance myCounter and
continues with Counter.prototype (which holds the prototype methods of class Count
er) and Object.prototype (which holds the prototype methods of class Object).
In this chapter, we’ll first explore instance objects and then classes as objects.
interface CountingService {
value: number;
22.3 Interfaces for classes 211
increment(): void;
}
Structural interfaces are convenient because we can create interfaces even for objects that
already exist (i.e., we can introduce them after the fact).
If we know ahead of time that an object must implement a given interface, it often makes
sense to check early if it does, in order to avoid surprises later. We can do that for instances
of classes via implements:
Comments:
• As an aside, private properties are ignored by interfaces and can’t be specified via
them. This is expected given that private data is for internal purposes only.
This is how we can check right away if class Person (as an object) implements the interface
JsonStatic:
If you don’t want to use a library (with the utility types Assert and Assignable) for this
purpose, you can use the following pattern:
Can we do better?
In line B, we use the satisfies operator, which enforces that the value Person is assignable
to JsonStatic while preserving the type of that value. That is important because Person
should not be limited to what’s defined in JsonStatic.
Alas, this alternative approach is even more verbose and doesn’t compile. One of the
compiler errors is in line C:
Why? Type Person is mentioned in line A. Even if we rename the type Person to TPerson,
that error doesn’t go away.
22.4 Classes as types 213
22.3.2 Example: TypeScript’s built-in interfaces for the class Object and
for its instances
It is instructive to take a look at TypeScript’s built-in types:
On one hand, interface ObjectConstructor is for the class pointed to by the global variable
Object:
On the other hand, interface Object (which is mentioned in line A and line B) is for in-
stances of Object:
interface Object {
constructor: Function;
toString(): string;
toLocaleString(): string;
valueOf(): Object;
hasOwnProperty(v: PropertyKey): boolean;
isPrototypeOf(v: Object): boolean;
propertyIsEnumerable(v: PropertyKey): boolean;
}
In other words – the name Object is used twice, at two different language levels:
class Color {
name: string;
constructor(name: string) {
this.name = name;
}
}
First, a constructor function named Color (that can be invoked via new):
assert.equal(
typeof Color, 'function'
);
class Color {
name: string;
constructor(name: string) {
this.name = name;
}
}
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
Why doesn’t TypeScript complain in line A? That’s due to structural typing: Instances of
Person and of Color have the same structure and are therefore statically compatible.
We can turn Color into a nominal type by adding a private field (or a private property):
class Color {
name: string;
#isBranded = true;
constructor(name: string) {
this.name = name;
}
}
class Person {
name: string;
22.4 Classes as types 215
#isBranded = true;
constructor(name: string) {
this.name = name;
}
}
This way of switching off structural typing is called branding. Note that the private fields
of Color and Person are incompatible even though they have the same name and the same
type. That reflects how JavaScript works: We cannot access the private field of Color from
Person and vice versa.
Let’s say we want to migrate the following code from the object type in line A to a class:
storePerson({
name: 'Robin',
});
In our first attempt, invoking storePerson() with an object literal still works:
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
storePerson({
name: 'Robin',
});
class Person {
name: string;
#isBranded = true;
constructor(name: string) {
this.name = name;
}
}
storePerson(
new Person('Robin')
);
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
217
218 23 Types for classes as values
}
}
What type C should we use for the parameter PointClass if we want the function to return
an instance of Point?
Depending on where we mention Point, it means different things. That’s why we can’t
use the type Point for PointClass: It matches instances of class Point, not class Point itself.
Instead, we need to use the type operator typeof (which has the same name as a JavaScript
operator). typeof v stands for the type of the value v.
Let’s omit the return type of createPoint() and see what TypeScript infers:
function createPoint(
PointClass: new (x: number, y: number) => Point, // (A)
x: number, y: number
23.3 A generic type for constructors: Class<T> 219
) {
return new PointClass(x, y);
}
The prefix new of its type indicates that PointClass is a function that must be invoked via
new.
Constructor type literals are quite versatile – e.g., we can demand that a constructor func-
tion (such as a class):
function f(
ClassThatImplementsInterf: new () => Interf
) {}
Similarly, construct signatures enable interfaces and OLTs to describe constructor functions.
They look like call signatures with the added prefix new. In the next example, PointClass
has an object literal type with a construct signature:
function createPoint(
PointClass: {new (x: number, y: number): Point},
x: number, y: number
) {
return new PointClass(x, y);
}
interface Class<T> {
new(...args: any[]): T;
}
class Person {
constructor(public name: string) {}
}
With cast(), we can change the type of a value to something more specific. This is also
safe at runtime, because we both statically change the type and perform a dynamic check.
The following code provides an example:
>>;
return cast(Object, parsed);
}
/**
* After invoking this function, the inferred type of `value` is `T`.
*/
export function throwIfNotInstance<T>(
TheClass: Class<T>, value: unknown
): asserts value is T { // (A)
if (!(value instanceof TheClass)) {
throw new Error(`Not an instance of ${TheClass}: ${value}`);
}
}
The return type (line A) makes throwIfNotInstance() an assertion function that narrows
types:
class TypeSafeMap {
#data = new Map<unknown, unknown>();
get<T>(key: Class<T>) {
const value = this.#data.get(key);
return cast(key, value);
}
set<T>(key: Class<T>, value: T): this {
cast(key, value); // runtime check
this.#data.set(key, value);
return this;
}
has(key: unknown) {
return this.#data.has(key);
}
}
222 23 Types for classes as values
The key of each entry in a TypeSafeMap is a class. That class determines the static type of
the entry’s value and is also used for checks at runtime.
map.set(RegExp, /abc/);
const re = map.get(RegExp);
assertType<RegExp>(re);
Class<T> does not match the abstract class Shape (last line):
Why is that? The rationale is that constructor type literals and construct signatures should
only be used for values that can actually be new-invoked. If we want to Class<T> to match
both abstract and concrete classes, we can use an abstract construct signature:
However, the new Class<T> works well for all other use cases, including instanceof:
23.3 A generic type for constructors: Class<T> 223
Therefore, we can rename the old type for classes to NewableClass<T> – in case we need a
class to be new-invokable:
You are reading a preview version of this book. You can either read all chapters online or
you can buy the full version.
225