Apress Modern Typescript
Apress Modern Typescript
TypeScript
A Practical Guide to Accelerate
Your Development Velocity
—
Ben Beattie-Hood
Modern TypeScript
A Practical Guide to Accelerate
Your Development Velocity
Ben Beattie-Hood
Modern TypeScript: A Practical Guide to Accelerate Your
Development Velocity
Ben Beattie-Hood
Melbourne, VIC, Australia
iii
Table of Contents
iv
Table of Contents
Chapter 4: Classes������������������������������������������������������������������������������81
Introduction���������������������������������������������������������������������������������������������������������81
Classes����������������������������������������������������������������������������������������������������������������82
Constructors��������������������������������������������������������������������������������������������������������82
Access Modifiers�������������������������������������������������������������������������������������������������82
Fields������������������������������������������������������������������������������������������������������������������83
Getters and Setters���������������������������������������������������������������������������������������������86
Methods��������������������������������������������������������������������������������������������������������������87
Inheritance����������������������������������������������������������������������������������������������������������87
Implements���������������������������������������������������������������������������������������������������������89
Static Modifier�����������������������������������������������������������������������������������������������������92
Warning 1: Classes Are Not Types�����������������������������������������������������������������������93
Warning 2: Classes Can Cause Scope Bleed�������������������������������������������������������95
Summary������������������������������������������������������������������������������������������������������������98
v
Table of Contents
vi
Table of Contents
Chapter 7: Performance��������������������������������������������������������������������213
Introduction�������������������������������������������������������������������������������������������������������213
Reducing Inline Types���������������������������������������������������������������������������������������214
Reducing Inferred Types�����������������������������������������������������������������������������������217
Inline Caching Using Conditional Types�������������������������������������������������������������221
Reduce Intersections����������������������������������������������������������������������������������������223
Reduce Union Types������������������������������������������������������������������������������������������225
Partition Using Packages and Projects�������������������������������������������������������������225
Partitioning Using Packages������������������������������������������������������������������������225
Partitioning Using Projects��������������������������������������������������������������������������226
Other Performance Tweaks�������������������������������������������������������������������������������228
Debugging Performance Issues������������������������������������������������������������������������228
Using the @typescript/analyze-trace NPM Package�����������������������������������229
Using a Trace Viewer Within Your Browser��������������������������������������������������230
Summary����������������������������������������������������������������������������������������������������������231
vii
Table of Contents
Chapter 8: Build��������������������������������������������������������������������������������235
Introduction�������������������������������������������������������������������������������������������������������235
Compiler Options�����������������������������������������������������������������������������������������������236
Recommended tsconfig.json Settings���������������������������������������������������������238
Other Options�����������������������������������������������������������������������������������������������246
Linting���������������������������������������������������������������������������������������������������������������247
Installing ESLint�������������������������������������������������������������������������������������������247
Ideal Ruleset������������������������������������������������������������������������������������������������248
Additional Strict Errors��������������������������������������������������������������������������������253
Additional Strict Warnings���������������������������������������������������������������������������257
Removed Rules��������������������������������������������������������������������������������������������261
Further Rules�����������������������������������������������������������������������������������������������267
JSX/TSX������������������������������������������������������������������������������������������������������������267
Modules������������������������������������������������������������������������������������������������������������274
Module Types Explained������������������������������������������������������������������������������274
Exports and Imports������������������������������������������������������������������������������������278
How TypeScript Resolves Modules��������������������������������������������������������������282
Summary����������������������������������������������������������������������������������������������������������286
Index�������������������������������������������������������������������������������������������������291
viii
About the Author
Ben Beattie-Hood is a principal software
engineer and professional mentor with over
20 years of industry experience. He currently
specializes in front-end technology, technical
strategy, system design, and development
and training in TypeScript, React, and related
technologies.
Ben is passionate about evolvable systems,
about creating learning organizations,
and about how ideas are formed and
communicated. He has given a wide range of talks covering product
development, event sourcing microservices, event storming practice,
modern database internals, functional programming, front-end design and
build, as well as coaching in TypeScript as a velocity tool.
Ben lives with his beautiful wife and two children in Melbourne,
Australia, where he loves to hike and travel.
ix
About the Technical Reviewer
Oscar Velandia is a front-end developer at
Stahls. He has worked the past four years
with Typescript, transitioning projects from
Vanilla JavaScript to TypeScript, creating MVP
projects, and working on large TypeScript,
Next.js, and React code bases.
xi
CHAPTER 1
History
To truly understand TypeScript, it is important to understand some of the
background of the language. We want to get onto the fun stuff with types,
but this background is necessary, so I’ll keep it short and to the point.
2
Chapter 1 How TypeScript Came to Be
3
Chapter 1 How TypeScript Came to Be
4
Chapter 1 How TypeScript Came to Be
Phew – that’s a lot to take in! But the main thing you need to know
is that during 2009–2010, there was a huge upsurge in the JavaScript
ecosystem. This continued to have exponential growth, until NPM became
the largest and most active package repository in the world and still
continues as such today, 2× larger than all other public package ecosystems
combined and with now over 32k packages published or updated every
month (Figure 1-2).
The Problem
It would be right to say that the JavaScript ecosystem, to put it mildly, is
flourishing. To keep up with the rapid development, NPM packages often
depend on each other – each package providing specialism in discrete
areas, with best-of-breed packages ever emerging. In one recent study, it
was found that over 60% of packages had a dependency chain greater than
5
Chapter 1 How TypeScript Came to Be
three layers deep; and another found that over 61% of packages could be
considered abandoned with dependent ecosystems likely to need to swap
them for replacements.
So it comes with the territory in JavaScript that one will be managing
dependencies, constantly working out what packages are compatible with
others, and trying to test against flaws. But if we have to face continually
updating packages, how will we know that an update will still work?
Scalable Velocity
Scalable velocity is important in software development because it enables
teams to maintain a consistent pace of development and predictable
expectations, even as the project grows in complexity and size. But with an
ever-shifting foundation, businesses faced needing to mitigate the risk of
continual unknown compatibility.
Fortunately, there are two tools built for ensuring scalable velocity:
Testing and Docs.
6
Chapter 1 How TypeScript Came to Be
But tests are a double-edged sword – they can help certainty and
thereby speed development; but exhaustive testing can slow development
where tests need large-scale review and updating after changes, and often
unit and E2E tests replicate product code and cause changes to effectively
need to be written twice.
7
Chapter 1 How TypeScript Came to Be
Summary
In this chapter, we explored the history of JavaScript and ECMAScript
and how these gave birth to a need for scalable velocity and how this was
then addressed through types in TypeScript. We saw how TypeScript’s
ability to perform compatibility checking and provide inline type lints
offered the best of both worlds, allowing teams to manage dependencies
more confidently and efficiently. And we saw how, as a result, TypeScript
adoption skyrocketed, with large-scale projects flocking to leverage its
benefits for documentation and static testing.
The stage now set, we are now primed with a better appreciation of
the language to delve deeper into TypeScript’s core features and dive into
the world of structural and computed typing. By harnessing TypeScript’s
power, you will be able to tackle complex projects, and lean into
JavaScript’s thriving ecosystem, with complete confidence.
8
CHAPTER 2
Getting Started
with the Developer
Experience
Introduction
In this chapter, we will set up your TypeScript development environment
and explore the essential tools and configurations needed to streamline
your TypeScript projects. A well-configured development environment
is crucial for maintaining scalable velocity and effectively managing
dependencies.
You will learn how to set up TypeScript from scratch with a no-frills
approach, allowing you to run TypeScript files directly from the command
line. We’ll walk through the process of creating a simple TypeScript file,
execute it using the ts-node package, and then leverage Visual Studio
Code’s debugging capabilities to pause execution and inspect your code
during runtime.
We’ll then explore a more realistic setup for TypeScript projects, one
that leverages best-of-breed packages from the JavaScript ecosystem. We’ll
introduce you to powerful build and management tools like NX, which
10
Chapter 2 Getting Started with the Developer Experience
Create a folder called “src”, and create a file called main.ts within it.
Add the following code to the file:
console.log('Hello world!');
Now, back in your terminal, type the following and press enter:
Congratulations, you’ve just run your first TypeScript file from the
command line!
Debugging TypeScript
Running TypeScript as shown in the preceding examples is fine, but you’ll
also need to be able to debug TypeScript in order to build at scale. Visual
Studio Code comes with support for this, so let’s set this up now.
In Visual Studio Code, navigate to debug panel (1); press the “create a
launch.json file” link (2) (Figure 2-1).
11
Chapter 2 Getting Started with the Developer Experience
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"runtimeArgs": [
"-r",
"ts-node/register"
],
"skipFiles": [
"<node_internals>/**"
],
12
Chapter 2 Getting Started with the Developer Experience
"program": "${file}"
}
]
}
That’s it! Now navigate to a line in your main.ts file that you’d like
execution to pause on, and press the F9 key on your keyboard, or click
in the left gutter, to create a breakpoint. Now, press the F5 key on your
keyboard to run your program, and you’ll see the IDE pause on your
breakpointed line.
If you want a quick scratch pad for trying out TypeScript code outside
your project, you might want to check out the “Quokka.js” extension for
Visual Studio Code. This extension will allow you to run TypeScript files
live as well as set up live tests against them. It’s an easy way to explore
the language more, and I’ve found it an invaluable tool. The free online
“TypeScript Playground” (www.typescriptlang.org/play) is another
similar resource.
Another useful extension to try in Visual Studio Code, while getting
into TypeScript, is “Pretty TypeScript Errors.” This extension will provide
a more readable breakdown of error messages, as well as links to further
information and sometimes a video on how to solve them.
Looking good! We can build, run, and debug. And we could go further
on this, as there are a lot more options we can pass to TypeScript via config
to use it more effectively. We’ll get into these later (see Build ➤ Compiler
Options); but rather than digging deeper and deeper into those now, let’s
just pause for a minute and take a reality check.
13
Chapter 2 Getting Started with the Developer Experience
ecosystem to build large scalable websites, back ends, or native apps. Most
books will suggest you clone a custom repo created by the author, pre-set
for these configurations; but to be frank, that’s not how I’d expect you to
use TypeScript in industry. Instead, let’s now set up TypeScript as part of a
toolchain in the best possible way that will be easy to keep up to date.
Sounds good? But with the JavaScript ecosystem moving so quickly,
how are we to keep continually up to date on all the “best practice”
configurations and NPM packages for our particular use cases?
Thankfully, the ecosystem has matured to a state where this can be
done for you by a couple of useful build and management tools – you no
longer need to retain a full-time job learning about build caching, module
bundle splitting, and the like – these can take care of these tasks for you.
The first of these is NX. Let’s try it out now. Create a new folder, open a
terminal there, and enter the following:
14
Chapter 2 Getting Started with the Developer Experience
Honorable Mentions
NX and Turborepo provide a solid basis for frictionless, enterprise-
scale development. If you’re interested in pushing the boundaries a bit
further, some other current tooling worth being aware of at present with
TypeScript is listed as follows, for further research:
15
Chapter 2 Getting Started with the Developer Experience
Summary
In this chapter, we learned how to get started with TypeScript, including
how to set up a simple project and how to use a templating tool for more
advanced use cases.
We covered the basics of TypeScript, such as installing the tsc CLI tool,
creating a configuration file, and compiling and debugging TypeScript
code. Running and debugging TypeScript files from the terminal and
Visual Studio Code allowed us to gain confidence in executing TypeScript
code efficiently.
We also explored more advanced use cases by using the templating
tool NX to generate your TypeScript configuration and allow easy running
and debugging of your app. These tools automated the configuration
process, sparing us from the need to retain a full-time job learning about
various build tasks and module bundling. By leveraging these tools,
we created a fully functional Next.js web app, powered by React and
TypeScript, without any manual configuration.
To enhance our development experience, we explored useful
extensions like “Quokka.js” and “Pretty TypeScript Errors,” which
provided us with live TypeScript code execution, better error messages,
and improved code readability. We also reviewed some additional tools
that reduce errors and/or speed development, in ESLint, Bun, Civet, and
RedwoodJS.
16
Chapter 2 Getting Started with the Developer Experience
17
CHAPTER 3
TypeScript Basics
Introduction
In this chapter, we will explore the basics of TypeScript’s structural
typing system, often known as “duck typing,” and how it differs from the
hierarchical types offered by traditional object-oriented programming.
Structural typing asserts that the shape or structure of an object is
more critical than its instance type or class. JavaScript uses runtime
structural typing, and TypeScript supports these structural types by
allowing the developer to specify them as contracts pre-build via explicit
lint annotations. TypeScript then uses these annotations to check that the
inferable type of a value matches the developer’s expectations.
In this chapter, we will explore the ways we can use TypeScript to
define these “minimum contract” types for values and how structural
typing allows various objects to fulfill that contract in a more maintainable
and scalable way than traditional inheritance and polymorphism.
We will also explore the concepts of type inference, narrowing and
widening, and the interpretation of types via an inbuilt mechanism called
“control-flow analysis.” We will look at optionality, what to do when you
have unknown types, and then begin a deeper dive into more advanced
concepts such as simple value vs. parameterized types.
Structural Typing
Dynamic languages such as JavaScript use a concept called “structural
typing.” This is more commonly called “duck typing.” Figure 3-1 shows the
usual obtuse and confusing picture people will often point you toward
when you ask “why is it called duck typing?”
Not very helpful, I agree. But the preceding image actually looks like
a duck and a rabbit (depending on what you’re looking for) – so the point
here is that it’s somewhat both things at the same time, like Schrödinger’s
cat, being both alive and dead simultaneously. And the analogy goes
that you’ll only really be able to determine if it’s one or the other type if
someone adds additional detail, like legs or nose.
What the duck-or-rabbit analogy, or “duck typing” nickname, is
getting at is that the type or the class of an object is less important than the
methods it defines.
20
Chapter 3 TypeScript Basics
In the preceding image, whatever is round can fit into the hole. It
doesn’t matter if it’s
Figure 3-3. Other items that match the same shape are accepted too
21
Chapter 3 TypeScript Basics
So that’s the way you can think of structural types: structural types
define a minimum shape (or structure) of a value. And it doesn’t matter
what the value is, what it inherits from, or anything else really – if it fits the
required minimum contract, it’s accepted. And while this may seem overly
simplistic, in practice, it turns out to be much more powerful, and scalable,
than alternative approaches.
This power can take some getting used to, if you come from an object-
oriented programming background and are more familiar with using
inheritance and polymorphism. So let’s illustrate the power of this with a
different example, this time with code. Take the following types:
Let’s say we wanted to put all those things into a cardboard box and
wanted to know if they’d all fit. If we were using polymorphism, we’d have
to inherit them all from some sort of “ThingWithASize” base class and then
define the function like this:
function addToBoxPolymorphically(thing:
ThingWithASizeAbstractType) {
// ...
}
23
Chapter 3 TypeScript Basics
You can pass any value into the function in the preceding JavaScript
code snippet, and the JavaScript runtime will only assert the value meets
the “minimum contract” of the type when the code evaluates a code line
where the value is further interrogated.
24
Chapter 3 TypeScript Basics
If you hover over the variable “fortyTwo” in your editor, you’ll see a
tooltip containing this:
❯ const fortyTwo: 42
25
Chapter 3 TypeScript Basics
In the tooltip, the “: 42” suffix isn’t the value – it’s actually the
TypeScript type constraint. Because we’ve used a constant, TypeScript is
saying that the only value that is the right shape for this box is the value 42
itself. Let’s try the same thing manually with a nonconstant by entering the
following in our editor instead:
This works. Now, if you try to assign the value again, it’ll only work
if the value assigned matches the “minimum contract” of our type
constraint:
26
Chapter 3 TypeScript Basics
// variable-length arrays:
const names: string[] = [ 'Ashley', 'Ted', 'Kim', 'Dave' ]; ✔
names[0] = 'Veronica'; ✔
names[0] = 42; ❌
27
Chapter 3 TypeScript Basics
// fixed-type array:
const values: [number, number, string, ...string[]] =
[41, 32, 'hi']; ✔
values[0] = 4; ✔
values[8] = 9; ❌
values[8] = 'something'; ✔
interface Employee {
name: string
employeeNumber: number
dateOfBirth: Date
address: {
street: string
postcode: string
}
isArchived: boolean
}
28
Chapter 3 TypeScript Basics
Once defined, we can use our custom object type to add a structural
contract to a value like this:
✔ // doesn't error
const amy: Employee = {
name: "Amy",
employeeNumber: 123,
dateOfBirth: new Date(2000, 9, 1),
address: {
street: "123 Street",
postcode: 1234
},
isArchived: false
}
29
Chapter 3 TypeScript Basics
// Simple parameters:
function greet(firstName: string, lastName: string): string;
// or, using a lambda style:
// const greet = (firstName: string, lastName: string):
string => ...;
draw({
type: 'square',
coords: [32, 48]
}); ✔ // doesn't error
draw({
type: 'square',
coords: 'top left'
}); ❌ // this will error
30
Chapter 3 TypeScript Basics
Optionality
In the preceding Employee type, we might have one or two fields that we
want to specify the type of only if they are present. We call these optional
fields. We can use a shorthand “?” optionality modifier to indicate them:
interface Employee {
name: string
employeeNumber: number
dateOfBirth?: Date // Optional
address?: { // Optional
street: string
postcode: string
}
isArchived?: boolean // Optional
}
✔ // doesn't error
const amy: Employee = {
name: "Amy",
employeeNumber: 123
}
This allows us to skip some fields if left undefined – while also ensuring
that if they are present, they are the right type.
31
Chapter 3 TypeScript Basics
If you’ve got sharp eyes, you will have spotted that the optional
parameters in the preceding examples are only ever trailing parameters.
This is because the parameters are provided by calling code “ordinally” (in
sequence), rather than by name – and so it would be impossible to write
valid calling code that worked for the following:
32
Chapter 3 TypeScript Basics
// For arrays:
const [name, age, ...others] = ["John", 30, true, "Red"];
❯ const name: string;
❯ const age: number;
❯ const others: [boolean, string];
33
Chapter 3 TypeScript Basics
// For objects:
const { name, age, ...others } =
{ name: "John", age: 30, isActive: true, color: "Red" };
❯ const name: string;
❯ const age: number;
❯ const others: { isActive: boolean, color: string };
// For arrays:
const [name, age, ...others] =
["John", 30, true, "Red"] as const;
❯ const name: "John";
❯ const age: 30;
❯ const others: [true, "Red"];
// For objects:
const { name, age } =
{ name: "John", age: 30, isActive: true, color: "Red" }
as const;
❯ const name: "John";
❯ const age: 30;
❯ const others: { isActive: true, color: "Red" };
34
Chapter 3 TypeScript Basics
// For arrays:
const lettersArray = ['b2', 'c3', 'd4', 'e5'] as const;
const newLettersArray = ['a1', ...lettersArray, 'f6'] as const;
❯ const newLettersArray: readonly [
"a1", "b2", "c3", "d4", "e5", "f6"];
// For objects:
const lettersObject = { b: 'b2', c: 'c3', d: 'd4', e: 'e5' }
as const;
const newLettersObject = { a: 'a1', ...lettersObject, f: 'f6' }
as const;
❯ const newLettersObject: {
readonly a: "a1", readonly b: "b2", readonly c: "c3",
readonly d: "d4", readonly e: "e5", readonly f: "f6",
};
35
Chapter 3 TypeScript Basics
These spread and rest operators work for type safety in function
parameters too:
However, you may find the readonly type assigned by the const
keyword to be restrictive, especially if you need to pass your resultant tuple
to a third-party library that isn’t as strict in its input definitions. If you hit
this annoying feature, you can take the following approach:
36
Chapter 3 TypeScript Basics
Async
Modern ECMAScript and JavaScript also support async functions. These
functions use the await keyword to return a Promise to their calling function
and pass the remainder of their function body to the underlying JavaScript
event loop to process when required resources are next allocatable.
And so, TypeScript includes native support for these async and await
keywords and their Promise return values – allowing you to use structural
types for these operators too:
37
Chapter 3 TypeScript Basics
Generators
Lastly, generator functions in JavaScript allow you to yield values during
iteration. You can think of them as working as if they had multiple
successive return statements; although in reality when a generator
function is called, it returns an iterator object, whose next, return, and
throw methods are then used to simulate a sequence of values that the
function yields.
Generator functions in ECMAScript (and therefore JavaScript) are
defined using the function* syntax, – similar to the usual function
keyword but with an asterisk (*) added after it. Inside the generator
function, one then uses the yield keyword to emit a value and pause the
execution of the function until the next value is requested.
TypeScript allows us to assign types to the returned Generator objects
and thereby ensure type safety in calling code.
38
Chapter 3 TypeScript Basics
Inferred Types
Now that we’ve explored structural types through primitives and interfaces
in TypeScript, let’s get back into the types and how they work. To do this,
we need to briefly review a powerful feature we touched on near the start:
type inference, widening, and narrowing.
Types, Automagically
As you may have noticed, we don’t have to add the actual types to our code
for TypeScript to already be able to work out what type a value is. This is
called type inference:
39
Chapter 3 TypeScript Basics
40
Chapter 3 TypeScript Basics
41
Chapter 3 TypeScript Basics
42
Chapter 3 TypeScript Basics
43
Chapter 3 TypeScript Basics
You can also explicitly widen a type yourself, too, if you like:
// Automatically widened:
let age = 40;
❯ let age: number
// Automatically narrowed:
function convertToString(o: object) {
if (o instanceof Date) {
// If the runtime reaches this point, TypeScript infers
// that 'o' will have all the methods available on a
// 'Date' object, and so automatically narrows the value
// to be a 'Date' type here:
44
Chapter 3 TypeScript Basics
return o.toUTCString();
❯ (parameter) o: Date
}
// ...etc
}
// Manually narrowed:
const inputElement = document.getElementById('my-input') as
HTMLInputElement;
/* (the 'as' keyword allows us to specify to the compiler
that we know that the 'my-input' element will always be an
HTMLInputElement, rather than the less-specific Element type
normally returned by getElementById) */
const ted = {
firstName: 'Ted',
lastName: 'Smith',
age: 28,
favorites: {
color: 'red',
icecream: 'choc chip'
}
}
45
Chapter 3 TypeScript Basics
46
Chapter 3 TypeScript Basics
interface Person {
firstName: string
lastName: string
age?: number // <- optional field
}
So TypeScript was also given one special case to cover the need
illustrated in the preceding example: “TS2322 Object literal may only
specify known properties.” This special case added a logic path to the
compiler asserting that when specifying a new inline value where an
47
Chapter 3 TypeScript Basics
explicit type is stated, it must not have any fields outside of the type stated.
And so TypeScript now guards against this issue in the following, more
helpful, way:
greet({
firstName: 'Ted',
lastName: 'Smith', ❌ // A helpful error here, alerting us
// to extra param
})
48
Chapter 3 TypeScript Basics
Type Assertions
Assertions are a way to tell the compiler more information about the type
of a variable than it can infer on its own. In the majority of cases, you
should avoid this, but there are cases where you are interacting with a safe,
known state that can help to share your external understanding of a system
with the compiler. Here’s an example of a simple assertion on a value:
Listing 3-33. Explicit types switch type checking back on again for
the value
49
Chapter 3 TypeScript Basics
Compile-Time Assertions
Compile-time assertions check and augment the types of your values
before the program runs. They are removed by the compiler before
runtime. Let’s look at them now.
as Keyword
The as keyword allows us to tell TypeScript that a value is of a particular
type. This is commonly known as casting, and it is by this name this
process is often called in TypeScript. But this isn’t actually accurate.
In most languages, you can freely cast from one type to another. But
as you could guess, this is equivalent to overriding type checking and, if
permitted in TypeScript, could lead to all sorts of runtime errors. So to
protect against this, TypeScript goes beyond traditional type casting to a
slightly more refined programmatic translation called type coercion.
Type coercion is the translation of one type to another. Using this,
TypeScript can determine that impossible coercions can be raised as
errors. For example:
Listing 3-34. TypeScript asserts casts are viable – here’s a case that
is not possible
interface Person {
firstName: string
lastName: string
}
interface HTMLElement {
nodeName: string
}
50
Chapter 3 TypeScript Basics
Listing 3-35. TypeScript asserts casts are viable – here’s a case that
is possible
interface Person {
name: string
}
interface Employee {
name: string
employeeNumber: number
department: string
}
51
Chapter 3 TypeScript Basics
Listing 3-36. TypeScript asserts casts are viable – but watch out for
accidental overlaps
interface Cat {
name: string
}
Happily, there are better ways to include data in a type than often using
the as keyword. We’ll cover a few of these in the other assertions, but check
out the “Union Types” section in Chapter 5 too, as that’s the most powerful
approach to take.
satisfies Keyword
Assigning types to variables brings us type safety, but sometimes, it can
also obscure tighter types that could be inferred. Take the following
example:
interface Person {
name: string
age: number
address?: {
52
Chapter 3 TypeScript Basics
street: string
postcode?: string
},
}
Using the Person type for the jim variable, we get type safety. But
looking more closely, we see that TypeScript now infers the variable as
follows:
53
Chapter 3 TypeScript Basics
Figure 3-5. Intellisense shows the field, even though the value
doesn’t have it
This is not particularly helpful – especially as we can see that the value
doesn’t have an address field. So, what can we do about it? This is where
the satisfies keyword comes in. Instead of marking the variable as a
type, we can instead specify that the variable only satisfies the minimum
contract provided by a type and is otherwise in its own shape:
❯ const jim: {
name: string
age: number
};
54
Chapter 3 TypeScript Basics
Listing 3-40. Use a tighter inferred type to keep even more detail
const jim = {
name: 'Jim',
age: 43,
} as const satisfies Person;
❯ const jim: {
readonly name: "Jim"
readonly age: 43
};
Figure 3-6. Intellisense shows only the fields applicable to the value
never Keyword
Last of the compile-time assertions is the never keyword. This keyword
allows us to state that a type can never be true. “Wait – what?” I hear you
say. When would we use such a type? In practice, this type allows for some
powerful assertions: exhaustivity checking and invalid return assertion.
55
Chapter 3 TypeScript Basics
Take a look at this function as an example. I’ve left off the return type.
What should I set it as?
In the preceding snippet, the function will never return a value – it will
always throw an error. Therefore, for the function’s return type, we use the
special TypeScript type, never:
Marking the return type as never allows the compiler to assert that
statements following the return are logically unreachable and therefore
could be an error:
throwAnError('This is an error');
56
Chapter 3 TypeScript Basics
This first way is a helpful way to instruct the compiler on logic flow,
but the second way is even more useful: the use of the never keyword is for
exhaustivity checking. Take the following example:
enum Color {
red,
blue,
green,
}
57
Chapter 3 TypeScript Basics
Instead, the never type allows us to assert that we have covered all
cases in our function by including a default case and checking that the
value is coercible to never:
enum Color {
red,
blue,
green,
magenta, // Added a new value here
}
58
Chapter 3 TypeScript Basics
default:
assertIsNever(color); ❌ /* TS now helpfully flags
here that the newly added
case is not covered. */
}
}
Then, when we add a color to our enum (or extend our planned union
type), the compiler will mark that the value is no longer coercible to never,
and we can easily update our code.
Runtime Assertions
Runtime assertions check the shape of your values while your program
is running; and their checks can be computed back into compile-time by
TypeScript to add pre-build checks too. We’ll cover them in detail in the
following subsections.
59
Chapter 3 TypeScript Basics
60
Chapter 3 TypeScript Basics
// ...
}
61
Chapter 3 TypeScript Basics
// ...
}
Custom Assertions
You can also write your own custom assertions, which wrap complex
checks in simple-to-use functions. To do so, these functions return a type
predicate, using a special is keyword as shown in the following example:
62
Chapter 3 TypeScript Basics
Existing Tools
Writing runtime assertions is possible, as seen in the preceding example,
and whenever interacting with errors and APIs, it is necessary. But it can
also be repetitive and has opportunities for errors. Happily, there are now
several libraries that can do this for you and that provide type assertions
back to TypeScript to use. Of the available libraries, one of the more
popular that aligns well with TypeScript’s own types is zod.
63
Chapter 3 TypeScript Basics
64
Chapter 3 TypeScript Basics
There are several libraries like this available (AJV, fp-ts, Joi, io-ts,
Yup, and others), and I would recommend experimenting with a few to find
which ones fit your own needs best.
In fact, all of the preceding cases can be condensed into the first case:
When interacting with an external API, what should we specify that a
returned value’s type is?
The truth is we don’t know what the returned value’s shape is until
we’ve checked it. And so for this, TypeScript includes two mechanisms
to mark a type as needing further assertion before safe usage. These
mechanisms are embedded in two keywords: any and unknown.
65
Chapter 3 TypeScript Basics
Listing 3-51. “any” switches off type checking for the value
Listing 3-52. Explicit types switch type checking back on again for
the value
const value: any = loadValue();
Originally any was the only inbuilt type shipped with TypeScript
that switched off the type checking, and so in some of the older libraries
available on NPM, these are still present in return types. But turning off
66
Chapter 3 TypeScript Basics
type checking in your APIs, when you’ve already explicitly decided to use
a type checker in order to achieve the benefits already discussed, would
be somewhat self-defeating. And so it was realized that what was often
desired was not an unchecked type (any), but a type that couldn’t be
known yet and needed further assertions before use – and so the unknown
type was added.
The unknown type works identically to the any type, with one major
difference: it requires runtime assertion before the compiler will permit it
to be used:
67
Chapter 3 TypeScript Basics
Due to the way that ECMAScript’s try/catch spec allows all values to be
thrown as error data, it’s usually best to assume that caught data is unknown
and needs interrogation before use. The same is true for API responses
(both local APIs and remote web APIs). These are the areas you are most
likely to need to use the unknown type and perform these assertions.
And we’ll look more deeply at type assertions next. But first a word of
warning.
Figure 3-7. Illustrative of the number of any types you may end
up using
68
Chapter 3 TypeScript Basics
The point I’m making here is that when learning, you may need to
use any and unknown lots of times, as you battle through reconciling types.
After gaining familiarity, using this book, you’ll start to see more and more
opportunities to use features such as mapped and conditional types to
help protect your code, and hopefully will begin aiming for a “near-zero-
anys” policy in your code – and this is an admirable aim. But don’t be
concerned if, once you start to master the language, you also find there
are times you need to augment TypeScript’s ability to interpret the types
with any or unknown, internally – within safely localized code, where the
values are known. But try not to expose any as a public return type: you will
usually be able to give consumers of your code more data about a value
than that – as we’ll explore in the rest of this book.
Parameterized Values
The types that we’ve covered so far are all for unparameterized values –
variables that return a data value directly, without requiring runtime
parameters, for example:
69
Chapter 3 TypeScript Basics
Listing 3-55. These values only return the data we need after we
have provided runtime parameters
interface Person {
// Defining simple fields in an interface or type
// is easy...
firstName: string
lastName: string
}
70
Chapter 3 TypeScript Basics
Index Signatures
Let’s start with the easiest first: types that can take index values before
providing a value. The following code is an example of this:
interface ColorDictionary {
the type of the index's key
[color: string]: string
the type of the value which is
returned or set
}
71
Chapter 3 TypeScript Basics
Function Signatures
Function signatures extend on index signatures and allow our value to take
one or more parameters before computing and returning a result.
interface LoadUserFunction {
// How do we write an interface or type for something
// that would take parameters before returning a result?
}
72
Chapter 3 TypeScript Basics
interface LoadUserFunction {
(usedId: number): User
}
interface LoadUserFunction {
(authToken: string): User
(authToken: string, dataCenter: string,
tenancyId: string): User
(email: string, password: string): User
}
73
Chapter 3 TypeScript Basics
Constructor Signatures
Lastly, constructor signatures – how would we define an interface for
something like the following code?
interface MyDateConstructor {
/* How do we specify parameters that will attach to
ECMAScript's new keyword, and call the type's special
constructor method? */
}
interface MyDateConstructor {
new (year: number, month: number, day: number):
MyDateAbstractClass;
}
class MyDateClass {
constructor(year, month, day) { }
}
74
Chapter 3 TypeScript Basics
75
Chapter 3 TypeScript Basics
greeter('hi');
greeter('bye');
76
Chapter 3 TypeScript Basics
interface CrazyShape {
// This crazy interface is both non-parameterised AND
// parameterised...
77
Chapter 3 TypeScript Basics
Summary
In this chapter, we explored the concept of “structural typing” in
TypeScript, also known as “duck typing,” where the shape of an object’s
structure takes precedence over its specific type or class.
We compared structural typing with traditional polymorphism and
inheritance, finding that structural types provide greater flexibility and
scalability, avoiding the need for brittle class hierarchies. We saw that by
defining a “minimum contract” for a value, TypeScript enables various
objects to fulfill that contract, thereby embracing the dynamic nature of
JavaScript while ensuring type safety.
Throughout the chapter, we learned how to add explicit types to
JavaScript as pre-build lint using TypeScript. We explored how the power
of structural typing, through examples, could simplify code and improve
scalability. We learned about various TypeScript features, including
custom types, array and object destructuring, spread, rest, optionality,
async functions, and generators, understanding how TypeScript ensures
type safety for each of these constructs.
We then delved into the fascinating world of type assertions, and
widening and narrowing, where TypeScript automatically refines types
based on context and conditions. We discussed compile-time and runtime
type assertions using operators like typeof, instanceof, and in, and how
to write custom type assertions using type predicates. And we explored
important tools like never and satisfies, which provide explicit control-
flow analysis and easier type narrowing, respectively.
At the same time, we discovered the importance of handling keywords
like any and unknown with caution, using them sparingly, and striving to
minimize their use over time to enhance code safety and maintainability,
but understanding their place in advanced usage.
78
Chapter 3 TypeScript Basics
79
CHAPTER 4
Classes
Introduction
In this chapter, we will explore how to add TypeScript type safety to the
central building block of object-oriented programming in JavaScript –
namely, classes.
We’ll begin by learning how to create classes in JavaScript and
TypeScript, with type-safe constructors, access modifiers, fields, getters/
setters, and methods; and we’ll explore type-safe inheritance and interface
implementation, and instance and static modifiers.
But while classes offer powerful abstractions for structuring code, it’s
essential to understand their limitations. And so the remainder of this
chapter will discuss the important difference between classes and types
in TypeScript and how using classes as types may lead to unexpected
behavior. Additionally, we’ll explore the concept of scope bleed, a common
issue associated with classes, and review how these objects fit (or not) in a
truly structurally typed context.
By the end of this chapter, you’ll have a solid understanding of classes
in TypeScript and how to use them wisely to create robust and scalable
applications. Embracing classes alongside functional programming
techniques understandingly will empower you to make well-informed
decisions about how best to write code that is performant as well as
maintainable and easily testable.
Let’s dive in and explore the world of classes in TypeScript!
Classes
To create a class in ECMAScript or JavaScript, you simply use the class
keyword and then also give it a constructor:
class MyClass {
constructor() {
// ...
}
}
Constructors
Constructors can have parameters, which, like function parameters, have
their types defined inline:
class MyClass {
constructor(myField: number) {
// ...
}
}
Access Modifiers
Access modifiers allow you to control the visibility and accessibility of
class members. TypeScript supports three access modifiers: private,
protected, and public.
82
Chapter 4 Classes
Fields
There’s literally zero point having a class if it doesn’t have state – you’d be
better off providing the functionality as a set of top-level functions. Class
state is held in fields, and in TypeScript, these can be assigned types:
class MyClass {
private myField: number;
constructor(myField: number) {
this.myField = myField;
}
}
You can also use shorthand notation to define fields directly in the
constructor parameters – the preceding snippet is functionally identical to
the following:
83
Chapter 4 Classes
This shorthand creates a myField field with the same name as the
parameter and assigns its value for you. Less coding and fewer bugs. These
are called parameter properties.
Fields and parameter properties can be assigned access modifiers to
change their exposure:
constructor(
privateField: number,
protectedField: number,
publicField: number,
) {
this.privateField = privateField;
this.protectedField = protectedField;
this.publicField = publicField;
}
}
84
Chapter 4 Classes
constructor(
publicField: number,
) {
this.publicField = publicField;
// ...but whoops we've forgotten to initialize it! So...
}
}
85
Chapter 4 Classes
class MyClass {
private _myField: number;
constructor(value: number) {
this.myField = value; // Calls 'set myField(value)'
}
}
86
Chapter 4 Classes
Methods
Methods are functions that belong to a class instance and therefore can
access the fields and other methods assigned to that instance. You can
define them using the same syntax as regular functions, but without
the function keyword prefix; and unless you specify an access modifier,
methods default to being public.
class Person {
constructor(public name: string) {
}
Inheritance
Inheritance allows you to create a subclass that inherits from a parent class
(a.k.a. superclass) and is performed using the extends keyword on the
subclass. As to whether you’d actually want to inherit from something, we
discuss in Chapter 3, in the “Structural Typing” section ; but if you do
need to use inheritance, then subclass constructors, if specified, must
explicitly call the superclass’s constructor, and so TypeScript will check this
for you if necessary:
87
Chapter 4 Classes
class Animal {
constructor(
private name: string,
private sound: string
) {
}
makeSound(): void {
console.log(`${this.name} is making a ${this.sound}
sound`);
}
}
You can also use the abstract keyword to define abstract classes and
abstract methods. An abstract class cannot be instantiated directly but
can be used as a base class for other classes. An abstract method does not
have an implementation and must be implemented by any non-abstract
subclasses – a bit like a placeholder, if you like:
88
Chapter 4 Classes
constructor(sideLength: number) {
super();
this.sideLength = sideLength;
}
getArea(): number {
return this.sideLength * this.sideLength;
}
}
Implements
Interfaces allow you to define contracts for classes and other types. You
can use the implements keyword to indicate that a class implements an
interface. Here’s an example:
interface Printable {
print(): void;
}
89
Chapter 4 Classes
interface Printable {
print(): void;
}
interface Loggable {
log(): void;
}
log(): void {
console.log('Logging...');
}
}
You can also use inheritance and implement interfaces at the same
time, as in the following snippet:
90
Chapter 4 Classes
interface Printable {
print(value: string): void;
}
class ConsoleLogger {
log(value: string): void {
console.log(value);
}
protected sanitize(value: string): string {
// etc
}
}
91
Chapter 4 Classes
Static Modifier
Fields, getters, setters, and methods can also be made static by marking
them with the static keyword. Static members belong to the class itself,
not to its instances. You can access static fields using the class name, both
internally and externally.
class MyClass {
private static _internalField: string = '';
static get value() {
return MyClass._internalField;
}
static set value(value: string) {
MyClass._internalField = value;
}
static getFormattedValue() {
return MyClass.value.toUpperCase();
}
}
92
Chapter 4 Classes
class MyClass {
private static getInitialInternalClockValue(): Date {
return new Date();
}
93
Chapter 4 Classes
class Human {
constructor (public name: string) {}
}
class Dog {
constructor (public name: string) {}
}
Therefore, any item that fulfills the minimum contract given by the
type can be passed in – be it a dog, a boat, or your favorite type of dessert!
94
Chapter 4 Classes
With this in mind, you can either start coding extra defensively:
or you can start embracing the functional paradigm more fully. More
on this in the following section.
95
Chapter 4 Classes
class Human {
constructor (public name: string, public dateOfBirth:
Date) { }
age(): number {
return new Date(
new Date().valueOf() -
this.dateOfBirth.valueOf()
).getUTCFullYear() - 1970;
}
}
Seems fine right? But consider this: the age method has access to the name
field. Coming from an object-oriented programming point of view, we might
simply shrug at this – what does it matter? But I put it to you that we only do
so because OO doesn’t allow us to change this – it just happens automatically.
However, let’s look at this more critically. In contrast to the
aforementioned, we would balk if we saw someone submit the following
code in a PR:
96
Chapter 4 Classes
The truth is that everything using the this parameter has access to
everything shared within the class. And this chuck-everything-into-a-
massive-scope-available-to-all-methods approach gets pretty tricky to test
accurately the more fields and methods we add, because shared state now
means the sequence you call the methods could impact test results and
add nx permutations.
So classes are, in practice, little bundles of mini global scopes and
associated statefulness that can be very difficult to test without knowing
in detail the code inside each method and how it impacts the scope
and state. This means that with classes, we somewhat couple the test
implementation to the class implementation, and you need knowledge
of both.
As a critical approach to this, I would recommend stepping back from
classes where possible and instead separating out behavior and data.
Consider making data immutable, passed explicitly through parameters
instead of ambiently available via mini global scopes; encapsulate
behavior in stateless functions and data in behaviorless types – and
essentially embrace a functional programming approach; and you will find
this structural language paradigm much easier to scale and maintain.
So although in this chapter we’ve covered how to use classes in
TypeScript, including fields, modifiers, methods, inheritance, and more,
and while classes can be a useful tool in object-oriented programming, it’s
important to remember that they are not the only solution and may not be
97
Chapter 4 Classes
the best fit for all situations. In many cases, I would recommend not using
classes, as without careful use, they can lead to the problems discussed,
used within this functional, structurally typed language. Instead prefer a
functional separation of immutable data and pure functions, and you’ll
find life easier.
Not fully convinced yet? Our next chapter will look at the amazing
power that comes from instead separating data from behaviors, by
allowing inline types and computed types. See you there!
Summary
In this chapter, we delved into the core concepts of classes in TypeScript, a
fundamental feature of object-oriented programming (OOP). We learned
how to create classes using the class keyword and how to use constructors
to initialize class instances with specific data. Access modifiers played a
crucial role in controlling the visibility and accessibility of class members,
fields held the state of class instances, getters and setters allowed us to
create computed properties and modify class fields securely, and methods
enabled us to perform various operations within the class, accessing
internal fields and other methods as needed.
We also covered inheritance, a core principle of OOP, allowing us to
create subclasses inheriting properties and behaviors from a parent class.
We learned how to use the extends keyword to create subclasses and how
to call the parent class’s constructor correctly. Abstract classes and abstract
methods provided a way to define placeholders in base classes, ensuring
that subclasses implemented these methods to fulfill the contract; and
interfaces allowed us to define contracts for classes and other types,
enabling type checking and code consistency.
However, with classes now fully understood, we also reviewed the
limitations of classes in TypeScript, with an eye on the difference between
classes and types. While classes can be used as types, they are not the
98
Chapter 4 Classes
99
CHAPTER 5
Computed Types
Introduction
Now that we have a deeper appreciation of the fundamentals of TypeScript
and how it works with the dynamic structurally typed language of
JavaScript, we are ready for the real fun to start! In this chapter, we will
explore a powerful feature of dynamic structural typing that is supported
by TypeScript, called computed types. These advanced types take
advantage of the runtime-typed approach of ECMAScript and JavaScript,
making TypeScript’s types even more dynamic and flexible than most
statically typed languages, and are one of the paradigm’s most powerful
features.
In TypeScript, we know a “type” is the minimum contract or shape of a
value, and we have already discussed various types like booleans, strings,
interfaces, etc. Now, by applying pointers or “aliases” to these, we can
reference parts or computations of types and use these to construct new
types. In this chapter, we will explore how type aliases can be used in a
range of these ways.
One we will cover is union types. These are powerful types in
functional programming languages, allowing us to guard against invalid
state. We will learn how to use union types effectively to ensure the correct
representation of data and restrict inputs to only valid options.
Type Aliases
All the shapes we’ve discussed so far – booleans, strings, interfaces, the in-
memory structural contract computed from a class, and dates – are types.
As discussed, a “type” is what we call the minimum contract or shape of
a value. And in TypeScript, because of the runtime-typed approach of
ECMAScript and JavaScript, we have some additional features to types
that make them even more powerful than most other statically typed
languages. We’ll explore these in this section on computed types.
102
Chapter 5 Computed Types
interface Person {
name: string
address: {
street: string
postcode: string
}
}
103
Chapter 5 Computed Types
name ‘Boris’
address neighboursCatName
Address
Figure 5-1. Type aliases are like variables in code, which instead
point to types rather than values
The first one is obvious and stylistic – and it’s your preference if you
want to use it. The other two we need to rewind and dig in a bit more to
understand the real power.
104
Chapter 5 Computed Types
105
Chapter 5 Computed Types
street: string
postcode: string
}
}
In this way, perhaps unlike any other language you’ve used, TypeScript
will allow you to extract previously unnamed pieces of types via type
aliases.
This has some important ramifications, which we’ll explore next.
106
Chapter 5 Computed Types
Union Types
Unlike most statically typed languages, ECMAScript and JavaScript allow
variable pointers to be reassigned to values of a different type from their
original value. For example, in JavaScript, you can do this:
// Foo is a number...
let foo = 9;
So how should we define the type of foo? Reading the preceding code
snippet, we know that foo’s type could be a number or a boolean or an
object structure. And so that is exactly how we can express it in TypeScript:
// Foo is a number...
let foo: number | boolean | { firstName: string, lastName:
string } = 9; ✔
107
Chapter 5 Computed Types
log("Bonjour!"); ✔ // Allowed...
log(new Date()); ✔ // ...also allowed...
log({ firstName: 'Jane', lastName: 'Scott' }); ❌
// ...nope, not allowed
This or-ing of types together is called a union type. But union types
don’t have names – they are defined inline. So how do we extract it to use it
elsewhere? We label it with a type alias.
108
Chapter 5 Computed Types
For example, let’s say we are storing the pets owned by the kids in
a school class. Some of the many options include cats, bugs, and fish
(Figure 5-2).
interface Bug {
name : string
numberOfLegs : number
hasWings : boolean
}
interface Fish {
interface Cat {
name :string
name : string
numberOfFins:number
furColor : string
}
}
Figure 5-2. How do we define the contract for something that could
be a cat, or a bug,... or even a fish?
109
Chapter 5 Computed Types
Listing 5-9. How should we define a type that can accept the valid data fields for
the possible pet variants?
110
Chapter 5 Computed Types
The problem with the enum+optional values approach is that it’s easy
to assign invalid data by mistake, because the enum value has no impact
on the value structure.
Instead, let’s try a union type, to say that only cats require fur, only bugs
have wings, and only fish have fins.
interface Child {
name: string
pet:
| {
name: string
furColor: string
}
| {
name: string
numberOfLegs: number
hasWings: boolean
}
| {
name: string
numberOfFins: number
}
}
111
Chapter 5 Computed Types
This partly solves it, as it prevents insufficient data. But it still fails
because the weird CatFishBug unfortunately fulfills the minimum contract
provided by any of the union options:
Listing 5-11. The union type allows greater type safety across
dependent fields, but it is not yet perfect
112
Chapter 5 Computed Types
Listing 5-12. Using a literal type within our union cases allows us to
distinguish them and thereby improve type safety
interface Child {
name: string
pet:
| {
type: 'cat' // Use a unique literal type to
name: string // differentiate the options.
furColor: string
}
| {
type: 'bug' // The unique literal allows
name: string // TypeScript to narrow correctly...
numberOfLegs: number
hasWings: boolean
}
| {
type: 'fish' // ...saving a bunch of typos, - and
name: string // preventing weird pet combinations!
numberOfFins: number
}
}
113
Chapter 5 Computed Types
114
Chapter 5 Computed Types
Intersection Types
As we saw before, ECMAScript and JavaScript allow for a spread operator:
Listing 5-13. Using the spread operator merges two or more values
together
const graphic = {
...path,
...fill,
};
But let’s think about that in more detail for a minute: What is the type
of the graphic variable?
In union types, we saw that we could “or” types to allow a choice of
options. As shown in the preceding code snippet, the shape of the graphic
variable is the combined shape of the path variable and the fill variable. This
is called an intersection type, and this is how you express it in TypeScript:
115
Chapter 5 Computed Types
type Graphic = Path & Fill; // Combines the two types together
const graphic: Graphic = {
...path,
...fill,
};
❯ type Graphic =
interface Path {
points: [number, number][]
joined: boolean
}
&
interface Fill {
color: string
};
116
Chapter 5 Computed Types
117
Chapter 5 Computed Types
Generic Types
Generic types are types that accept type parameters to modify their
resulting shape. These type parameters are specified by the consuming
code and act as placeholders for types – a little like type aliases, but scoped
to a single type.
Type Parameters
The following example shows the use of a type parameter. The type
parameter is provided (input) at the top of the type in <> brackets and then
can be used (output) anywhere inside the type:
The type of the items field in the preceding code snippet’s List
object is not pre-known by the type – but using the T type parameter as
a placeholder, we can allow the type to be specified by the consumer,
like this:
118
Chapter 5 Computed Types
interface List<T> {
items: readonly T[]
// ...etc
}
119
Chapter 5 Computed Types
120
Chapter 5 Computed Types
const sortedNames =
['Zoe', 'Annabel', 'Ted', 'Ross']
.sort(compare); TypeScript can infer the generic
parameters
121
Chapter 5 Computed Types
One way we’ve discussed persisting the literal values of the types is by
marking the value as const, as in the following code example:
122
Chapter 5 Computed Types
Try this approach out if you find yourself writing as const too much. It
is a helpful way to keep the type safety without the overhead.
Generic Constraints
Type parameters can be given constraints too. Take the following example:
123
Chapter 5 Computed Types
Here, we want to find the item by name – but if we don’t know the type
of T, then how do we know it has a name field to match on? So we add a
constraint:
These constraints act like type shapes, and so it can be tempted to use
generics for more than the necessary:
124
Chapter 5 Computed Types
const colors = {
red: '#FF0000',
green: '#00FF00',
blue: '#0000FF',
} as const;
❯ const colors: {
readonly red: "#FF0000";
readonly green: "#00FF00";
readonly blue: "#0000FF";
}
125
Chapter 5 Computed Types
Want to revisit what we covered earlier for inferred types? You can
find the earlier information in Chapter 3.
Inferred types help reduce boilerplate, sure. But what if you want to
use that type – store it, reuse it, or manipulate it? TypeScript includes two
helpful type keywords that allow you to do just this.
const colors = {
// ...
} as const;
126
Chapter 5 Computed Types
127
Chapter 5 Computed Types
And of course, once you have the type, you can still do all the usual
type inference and extraction you’d normally be able to do:
const colors = {
red: '#FF0000',
green: '#00FF00',
blue: '#0000FF',
};
type Colors = typeof colors;
❯ type Colors {
red: string
green: string
blue: string
}
Listing 5-32. Using the keyof keyword to infer the type of a type’s
indexer key
128
Chapter 5 Computed Types
However, if you try the following code, the type of keys will be
string[], and not a union:
Listing 5-33. Using the keyof keyword to infer the type of a type’s
indexer key
Listing 5-34. Using the keyof keyword to infer the type of a type’s
indexer key
const ashley = {
firstName: 'Ashley',
lastName: 'Emmerson',
age: 48,
someAdditionalCompletelyRandomField: 42
}
129
Chapter 5 Computed Types
If you prefer this approach, you may want to explore the ts-reset
NPM package (www.npmjs.com/package/@total-typescript/ts-reset),
which attempts to add stricter typing to some of the defaults shipped in
TypeScript.
130
Chapter 5 Computed Types
131
Chapter 5 Computed Types
Utility Types
TypeScript comes with a pre-built suite of utility types that you can use
when constructing and intersecting your own types, to save time and
maintenance. We’ll be able to build our own later in this book once you get
familiar with some of the more advanced concepts, but let’s get a view of
the most commonly used built-in ones now in this section and how we can
use them.
Record<Keys, Type>
Most languages come with a dictionary or hashtable utility type, and
JavaScript is no exception with its simple object, which allows developers
to assign new keys and corresponding values at runtime. TypeScript
comes with a built-in type called Record<Keys, Type> utility type that
allows you to create a new type that has a set of specified properties with
corresponding value types. Here’s how you use it:
132
Chapter 5 Computed Types
This creates a type that has a string key and a Person value for each
key. As it is a dictionary type and not a fixed shape, it allows key/value
pairs to be added and removed from it, so long as the keys and values
conform to the requirements:
Pick<Type, Keys>
The “Pick” utility type allows you to create a new type that includes only
certain properties from an existing type:
interface Person {
name: string;
age: number;
address: string;
}
133
Chapter 5 Computed Types
const fruit = {
bananas: 'Yellow',
kiwis: 'Green',
watermelons: 'Pink',
} as const;
const vegetables = {
carrots: 'Orange',
potatoes: 'White',
cherries: 'Red',
} as const;
134
Chapter 5 Computed Types
const shoppingColors = {
...pick(fruit, 'bananas', 'watermelons'),
...pick(vegetables, 'carrots', 'cherries'),
};
❯ const shoppingColors: {
carrots: "Orange";
cherries: "Red";
bananas: "Yellow";
watermelons: "Pink";
};
Using Pick<Type, Keys> in this way, we see we retain the type safety of
both keys and values while keeping our code simple to read.
Omit<Type, Keys>
The Omit<Type, Keys> utility type allows you to create a new type that
excludes certain properties from an existing type. It is the exact inverse
of the Pick<Type, Keys> utility type. Here’s the same example as shown
previously, but instead using Omit<Type, Keys>:
interface Person {
name: string;
age: number;
address: string;
}
135
Chapter 5 Computed Types
age: number;
};
Don’t worry if that seems a lot to take in right now. We’ll look into
how this works in more detail later. For now, let’s use it like we did with
Pick<Type>, to build an omit function:
136
Chapter 5 Computed Types
And then let’s use this like we did with the pick function:
const fruit = {
bananas: 'Yellow',
kiwis: 'Green',
watermelons: 'Pink',
} as const;
const vegetables = {
carrots: 'Orange',
potatoes: 'White',
cherries: 'Red',
} as const;
const shoppingColors = {
...omit(fruit, 'bananas'),
...omit(vegetables, 'carrots', 'cherries'),
};
❯ const shoppingColors: {
potatoes: "White";
kiwis: "Green";
watermelons: "Pink";
};
Notice how using OmitStrict<Type, Keys> (or the more loosely typed
Omit<Type, Keys>) we again retain the type safety of both keys and values
while keeping our code simple to read.
137
Chapter 5 Computed Types
Partial<Type>
The Partial<Type> utility type allows us to create a new type that makes
all properties of an existing type optional. Using our same Person example,
we can make the fields optional:
interface Person {
name: string;
age: number;
address: string;
}
One example of use for this is in a value setter for a store, where any
fields you might assign are saved to the store:
interface Store<T> {
value: T
setValue: (newValue: Partial<T>) => void
}
138
Chapter 5 Computed Types
age: 80,
});
Required<Type>
Like Pick<Type, Keys> and Omit<Type, Keys>, the built-in
Required<Type> utility type is the handy inverse of Partial<Type>. Here’s
an example:
interface Person {
name: string;
age?: number;
address?: string;
}
139
Chapter 5 Computed Types
Readonly<Type>
In TypeScript, you can preface fields with the readonly keyword to make
them immutable:
interface Person {
readonly name: string;
readonly age: string;
}
140
Chapter 5 Computed Types
However, if you want to do this for all the fields within a type, this gets
cumbersome. And so the Readonly<Type> utility type allows you to create
a new type that makes all properties of an existing type readonly and
therefore cannot be changed:
interface Person {
name: string; // No need to repeat the readonly
age: number; // keyword on every property
}
141
Chapter 5 Computed Types
However, it’s worth knowing that this type, as well as the result of
Object.freeze(t), is shallow. This means that although direct fields of the
object cannot be edited, nested subobjects and arrays can still be edited,
and so sometimes you will want a recursive alternative to Readonly<Type>,
such as the following example:
type DeepReadonly<T> =
T extends (undefined | null | boolean | string | number |
Function)
? T // Primitives are immutable by default.
: T extends Array<infer U>
// Arrays, maps & sets have special
? ReadonlyArray<DeepReadonly<U>>
// built-in readonly modifier types.
: T extends Map<infer K, infer V>
// ...ditto
? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
// ...ditto
: T extends Set<infer M>
// ...ditto
? ReadonlySet<DeepReadonly<T>>
// ...ditto
: { readonly [K in keyof T]: DeepReadonly<T[K]> };
// Deep recursion.
Don’t worry about those tricky infers and ternaries yet – we’ll get to
these in the “Conditional Types” section, later in this book. For now, take
the preceding code snippet as a handy recursive implementation that will
protect even deep field values, as shown in the following:
142
Chapter 5 Computed Types
interface Person {
name: string;
age: number;
addresses: {
street: string
postcode: string
}[]
}
If you’d like to use immutable data structures in more depth, it’s best to
use the library “Immer”.
To remove the readonly effect on a type, you can use the following
utility type:
143
Chapter 5 Computed Types
Exclude<Type, Keys>
The Exclude<Type, Keys> utility type creates a new type by excluding all
keys that match the Keys parameter from the type specified by the Type
parameter. Mainly you’ll use this to reduce union types to cases you want
to support, in cases similar to the following:
interface ComponentPropsA {
foo: string
bar: number
}
interface ComponentPropsB {
foo: () => void
baz: number
}
144
Chapter 5 Computed Types
const componentPropsA = {
foo: 'Hello',
bar: 42
}
const componentPropsB = {
foo: () => console.log('Hello world'),
baz: 3
}
const mergedComponentProps = {
...componentPropsA,
...componentPropsB, // foo here overwrites, so is a lambda
// not a string
}
145
Chapter 5 Computed Types
Listing 5-57. Use the exclude utility type to avoid overlapping keys
type MergedComponentProps =
ShallowMerge<ComponentPropsA, ComponentPropsB>
❯ type MergedComponentProps = {
foo: () => void; // Much better!
bar: number;
baz: number;
}
In fact, Omit actually uses Exclude internally for this very sort of
thing, and so the preceding code snippet can be simplified further to the
following:
146
Chapter 5 Computed Types
Extract<Type, Keys>
As with Pick/Omit and Partial/Required, the Extract<Type, Keys>
utility type is the invert of the Exclude<Type, Keys> utility type. As such,
it creates a new type by excluding all types that match the Keys parameter
from the type specified as Type. So you can use it as a convenience type in
similar situations:
Parameters<FunctionType>
The Parameters<FunctionType> utility type extracts the parameter types
of a function type and returns them as a tuple.
147
Chapter 5 Computed Types
This can be useful in many cases. One such case is ensuring wrapping
functions match parameters without needing to code them yourself:
Another handy use for this is gaining reference to types not directly
exposed by third-party libraries that you need to use:
---------------------------------------------------------------
148
Chapter 5 Computed Types
Notice we’re using the indexor [0] to extract the first (albeit only)
parameter from the parameters tuple in this case. See the “Parameterized
Values” section for more info.
ConstructorParameters<ClassType>
As with the Parameters<FunctionType> utility type, the Constructor
Parameters<ClassType> utility type extracts the parameter types of a
constructor function type and returns them as a tuple:
class Shape {
constructor(x: number, y: number, color: Color) {}
}
type ShapeConstructorParameters =
ConstructorParameters<typeof Shape>;
❯ type ShapeConstructorParameters = [number,
number, Color];
149
Chapter 5 Computed Types
ReturnType<FunctionType>
The ReturnType<FunctionType> utility type extracts the return type of a
function type.
150
Chapter 5 Computed Types
But unfortunately this doesn’t work well when inferred types require
generic parameters. For example, this won’t compile:
151
Chapter 5 Computed Types
Conditional Types
From this point onward, things are going to get even more interesting.
Let’s look at another feature of JavaScript being a dynamic language. In the
following example, what is the return type of the function?
The return type depends on the literal type of “s”, so we need to make
“s” into something we can interrogate as a type. We can do this by making
it a generic parameter:
152
Chapter 5 Computed Types
Listing 5-68. Store the subject type of the condition so that we can
use it to compute the return type.
Using the generic parameter, we can use TypeScript to encode the logic
of the function as a type ternary, called a conditional type. And to make
this, we need to use the extends keyword.
extends Keyword
Knowing the logic inside the parseBoolean function, we can write its
return type as a type ternary, or “conditional type,” like this:
type ParseBooleanResult<S> =
S extends 'yes' ? true
: S extends 'no' ? false
: never;
const a = parseBoolean('yes');
❯ const a: true;
const b = parseBoolean('no');
❯ const b: false;
const c = parseBoolean('carrots and bananas');
❯ const c: never;
153
Chapter 5 Computed Types
interface Person {
name: string
email?: string
phone?: string
address?: {
street: string
postcode: string
}
}
// Here the extends keyword allows us to do pattern-matching
// against type T.
type PrimaryContactDetails<T extends Person> =
T extends { email: string } ? T['email']
: T extends { phone: string } ? T['phone']
: T extends { address: string } ? T['address']
: never;
154
Chapter 5 Computed Types
const x = getPrimaryContactDetails({
name: 'Dave',
email: '[email protected]',
phone: '012345678',
} as const)
❯ const x: "[email protected]";
// The 'email' case has precedence in the type's
ternary's ordering
infer Keyword
Where the extends keyword allows us to use pattern matching to compute
type shapes, the infer keyword takes the pattern matching even further to
give you access to the parts of the pattern you didn’t match on.
Let’s take an example to illustrate. Let’s make a type that will give us
the “head” element of an array (the first item in the array). Using pattern
matching, we could do it like this:
155
Chapter 5 Computed Types
But we can see in the preceding example that it would be hard to cover
every case, and our pattern matching would end up unmanageably long.
Instead, the infer keyword allows us to just pattern-match that T is an
array and then infer the actual type of the array from the match:
Well, that was a lot simpler, wasn’t it? The infer keyword allows us
to infer and store the rest of a matched type into an inline type, which can
then be used a bit like a generic parameter thereafter.
156
Chapter 5 Computed Types
This ability to infer types is very powerful and can be used in a lot of
handy ways. For example, you can infer the value of a generic parameter:
type HeadOf<T> =
T extends Iterable<infer Head> ? Head
: never;
type ParametersOf<T> =
T extends (...params: infer Params) => any ? Params
: never;
// ...or even a function's returned type:
type ReturnOf<T> =
T extends () => infer Returned ? Returned
: never;
157
Chapter 5 Computed Types
And lastly you can pattern-match on tuples and infer their contents:
type MixItUp<T> =
T extends []
? []
: T extends [infer First]
? [First]
: T extends [infer First, infer Last]
? [Last, First] //
: T extends [infer First, ...infer Middle, infer Last]
? [...Middle, First, Last] // !
: never;
158
Chapter 5 Computed Types
interface WrappedType<T> {
id: number
name: string
description: string
value: T
}
159
Chapter 5 Computed Types
If you need more than one type returned, you can return them as an
inline type instead:
160
Chapter 5 Computed Types
type ArrayOf<T> =
T extends any ? T[]
: never;
// Given...
type A = ArrayOf<string | number>;
// ...TS's conditional typing is 'distributed' onto each member
// of the union, therefore the above becomes equivalent to...
type A = ArrayOf<string> | ArrayOf<number>;
// ...whose individual conditional types then evaluate down to
// essentially:
type A = string[] | number[];
161
Chapter 5 Computed Types
Not helpful if you want to store both, right? Well, there’s a neat trick
we can use to make unions nondistributive, which is to wrap the generic
in a tuple during comparison – which circumvents TypeScript’s default
behavior:
162
Chapter 5 Computed Types
163
Chapter 5 Computed Types
Mapped Types
Conditional types, as we’ve just looked at, are a form of what are called
computed types – types that compute a target type from a source type.
In conditional types, we’ve seen how to perform a simple wholesale
translation of source to result type. In mapped types, we can perform a
more granular transformation by translating each individual field in the
source type.
type StringStringDictionary = {
[key: string]: string
}
favouriteThings['color'] = 'Red';
If we couple this with Ok, fine – but we’ve covered indexed types. How
do they help us transform? Well, let’s refine the indexed type we have to
take a generic parameter:
164
Chapter 5 Computed Types
Listing 5-85. Adjusting the index to match the keys of the type
supplied via the generic parameter
This is now a mapped type – it has exactly the same fields as the type
specified in the generic parameter, but redefines (maps) the field values to
new types, in this case strings. Let’s see how this works in a more practical
example:
interface Person {
firstName: string
lastName: string
}
validatedPersonData['firstName'] = true; ✔
validatedPersonData['lastName'] = false; ✔
validatedPersonData['color'] = true; ❌ // Invalid key
validatedPersonData['lastName'] = 'Foo'; ❌
// Invalid value
Great – now we’ve constrained the key to match the key of a generic
type parameter. But we’re still not exactly transforming the type – how do
we do that?
165
Chapter 5 Computed Types
Now that we have constrained the index key, we can use that index key
as a field reference to reference each field, as follows:
Listing 5-87. Mapping our field values to the types in the originating
type supplied via our generic parameter
Listing 5-88. Extending the field values in our mapped type to also
permit null
// Transforming (mapping) the type: Changing the type of each field
// in this case to also allow null.
type Nullable<T extends object> = {
[Key in keyof T]: T[Key] | null
}
const doug: OurType<Person> = {
firstName: 'Doug', ✔ // All fields now allow values per their
lastName: null, ✔ // originating type, OR null, to be
// assigned.
age: 'Ten', ❌ // But we can't assign incorrect
// type values,
aage: 5, ❌ // nor make typos.
}
166
Chapter 5 Computed Types
interface Person {
name: string
age: number
dateOfBirth: Date
}
interface HistoryEntry<T> {
value: T
effectiveDate: Date
}
167
Chapter 5 Computed Types
type History<T> = {
[Key in keyof T]: HistoryEntry<T[Key]>[]
}
168
Chapter 5 Computed Types
169
Chapter 5 Computed Types
You can also use the ShallowMerge utility type from Chapter 7 as
a safer alternative than direct type intersection when adding fields.
But how can we remove fields? As seen before, we can use the
Exclude<T, U> utility type to remove values from a union. keyof T is a
union, so we can use Exclude<T, U> to remove keys from it:
interface Employee {
name: string
employeeNumber: string
favoriteColor: string
}
const bob = {
name: 'Bob Smith',
employeeNumber: 'A01234',
favoriteColor: 'Purple',
} satisfies Employee;
170
Chapter 5 Computed Types
Renaming Fields
As well as adding and removing fields, you can also use mapped types to
rename fields. To do this, you use the as keyword to cast the field key to
a new type. The following code example shows the cleanest way of doing
this, by casting to a generic type that can do the renaming for us. This
example makes use of conditional types that we’ve already covered, as well
as template literals that are covered later in this chapter:
interface Person {
firstName: string
lastName: string
age: number
}
type Rename<T> = {
[Key in keyof T as RenamedField<Key>]: T[Key]
}
171
Chapter 5 Computed Types
lastName__renamed: string;
age__renamed: number;
}
Recursive Types
As well as mapping over the contents of a type, you can also map
recursively into a type, using type recursion. We will explore two ways of
doing this: recursion within an object type and recursion over a tuple type.
type SaveData<T> = {
id: number
value: T
dateSaved: Date
}
172
Chapter 5 Computed Types
interface Person {
firstName: string
lastName: string
address: {
street: string
}
}
173
Chapter 5 Computed Types
type MapValue<T> =
T extends ...etc // Fill this out with your own
// mapping conditional
Why would we do this? When you are dealing only with an array, it’s
easy to change the type as shown in the following code:
174
Chapter 5 Computed Types
type SaveData<T> = {
id: number
value: T
dateSaved: Date
}
175
Chapter 5 Computed Types
So instead we can map the returned tuple type to preserve the const
type data:
176
Chapter 5 Computed Types
Template Literals
Template literals allow you to embed type expressions within string literals.
To define a template literal, use backticks “`” rather than simple quotes
around your string literal, as shown in the following example:
177
Chapter 5 Computed Types
As these template literals can include any type that can be evaluated to
a string, you can include union types in them:
178
Chapter 5 Computed Types
Summary
In this chapter, we explored the amazing concept of “computed types” in
TypeScript, which leverages the runtime-typed approach of ECMAScript
and JavaScript to make TypeScript’s type system even more powerful and
flexible. This will be a new concept to many, and don’t worry if this chapter
needs revisiting a few times while you get to grips with this idea.
To recap, in this chapter, we began with understanding type aliases,
which act as pointers to types, or pieces of types. Type aliases enable us to
write cleaner and more maintainable code by allowing us to reuse types
rather than duplicating them. And in this chapter, we used these type
aliases to provide pointers to our computed types, which would otherwise
be anonymous.
Then the first computed type we reviewed was union types, which
are essential for guarding against invalid states. These union types allow
us to constrain only to the valid options and help us enforce specific
requirements for each option.
Intersection types were the next computed type we explored, which
allowed us to combine multiple types into new types, reducing code
duplication and allowing us to instead design types around single
responsibilities – picking and merging them as needed and reducing our
ongoing type definitions overall.
179
Chapter 5 Computed Types
180
CHAPTER 6
Advanced Usage
Introduction
By this chapter, you will have covered now all the types needed to build
truly amazing software. In this chapter, we will use the types you’ve learned
and combine them to solve advanced use cases, as a form of master class
in TypeScript. For each type I will present, we will review the needs of the
type, consider what types we have in our toolbelt that can address these
needs, and then combine and build our advanced types step-by-step
from the ground up together, so you can see how they’re built and how to
make the choices necessary when practicing TypeScript at an advanced
proficiency.
We will start with the simpler of these advanced type challenges and
use these to create types that in turn can be used to validate stages in
the latter advanced types. And in finishing, I’ll also list some places for
further exploration of advanced types that can help you continue your
learning journey and connect you to communities that can help you as you
progress.
Now, on to the first advanced case!
182
Chapter 6 Advanced Usage
// It works!
type Result = IsEqual<1, 1>; ✔
// It works!
type Result = IsEqual<1 | 2 | 3, "Invalid value">; ❌
// It doesn't work
type Result = IsEqual<1 | 2 | 3, 1>; ✔
The problem is that in that last type, we want IsEqual to assert that the
first and second arguments are equal types, not just that B is compatible
with A. To solve this, break it into two parts: equality and then assertion.
The equality part is tricky. We can’t use a simple conditional like the
following because that’ll do the same thing – only compare compatibility:
We’re going to need to go a bit deeper here. To solve this, we’re going to
use a feature of how conditional operators are implemented internally in
TypeScript (this is the Advanced Usage chapter, alright?): conditional types
allow you to compare extends against generic functions and types; but
183
Chapter 6 Advanced Usage
to allow this, they would need to be able to compare the type parameters
of the generics too, and these generic parameters are unknown so they
cannot be compared. Instead, conditional operators internally defer
the match and require the generic parameters and related types of both
branches match each other directly. And this direct match is what we want
to leverage for our IsEqual type. The result is this:
Listing 6-4. Using deferred types to match cases exactly, rather than
matching via extension
Great! Now we have a type that checks types are equal. Now, we can
use a type constraint to surface the true/false result:
type ResultN =
Assert<IsEqual<1 | 3 | 3, 1>>; ❌ // Yay, it works!
type ResultY = Assert<IsEqual<1, 1>>; ✔ // Yay, it still works!
184
Chapter 6 Advanced Usage
Compute
When you make a computed type in TypeScript, it will display the type
as shown in Figure 6-1.
185
Chapter 6 Advanced Usage
Figure 6-1. TypeScript doesn’t always show the most useful type
Listing 6-6. Copying the keys from a complex type (e.g., intersection)
onto a new type in order to simplify them
186
Chapter 6 Advanced Usage
Great! But nested types (e.g., address field) are still obscured. To solve
this, we can use a recursive intersection type to force computation of all
the fields, as follows:
type Compute<T> = {
[Key in keyof T]:
T[Key] extends Primitive | Date
? T[Key]
: Compute<T[Key]>
};
187
Chapter 6 Advanced Usage
However, in practice, this does exactly the same thing (Figure 6-2).
What we need is to force TypeScript to process all the fields, and not
take any shortcuts. One way to do this is to use an intersection type (&) –
as discussed before, TypeScript merges all the fields from an intersection
rather than overriding them, and to do this, it has to process them. We
don’t need to intersect to add any new fields, so we just intersect against a
known empty type – the easiest built-in one being the unknown type:
type Compute<T> = {
[Key in keyof T]:
T[Key] extends Primitive | Date
? T[Key]
: Compute<T[Key]>
} & unknown;
188
Chapter 6 Advanced Usage
address: {
street: string;
};
}
JsonOf
Let’s now use some more of our computed types to build the return
type for this function:
Listing 6-9. Our target use case for a JsonOf<T> utility type
189
Chapter 6 Advanced Usage
interface Person {
id: number
name: string
dateOfBirth: Date
address: {
street: string
postcode: string
movedInOn: Date
}
}
const jenny = {
id: 1,
name: 'Jenny',
dateOfBirth: new Date(1990, 1, 1),
address: {
street: '123 Some Street',
postcode: '1234',
movedInOn: new Date(2020, 1, 1),
},
} satisfies Person;
190
Chapter 6 Advanced Usage
type JsonOf<Person> = {
id: number
name: string
dateOfBirth: string
address: {
street: string
postcode: string
movedInOn: string
}
}
From To
boolean boolean
number number
string string
Date string
191
Chapter 6 Advanced Usage
192
Chapter 6 Advanced Usage
address: { ✔
street: string; ✔
postcode: string; ✔
movedInOn: Date; ✔ // Yay, it worked!
};
}
But thinking more about it, why not allow JsonOf to be used on more
than object types, as in practice, strings, numbers, objects, and arrays, all
can be converted to JSON. So finally, let’s invert our mapped type so that
the conditional is topmost:
type JsonOf<T> =
T extends string | number | boolean ? T
: T extends Date | symbol ? string
: T extends object ? {
[Key in keyof T]: JsonOf<T[Key]>
}
: T extends (infer Item)[] ? JsonOf<Item>
: never;
type A = JsonOf<string>; ✔
type B = JsonOf<number>; ✔
type C = JsonOf<Person>; ✔
type D = JsonOf<Person[]>; ✔
193
Chapter 6 Advanced Usage
One last step to take now – when converting to JSON, all function
values are set to null in arrays and are removed from objects. Setting to
null is easy, but excluding functions is more tricky and needs a couple of
utility types. Let’s do that next:
// Utility type 1: Map the keys to their values, but may any
// key whose value is of type ExcludeValues to never
type ExtractKeysNotMatching<T extends object,
ExcludeValues> = {
[Key in keyof T]: T[Key] extends ExcludeValues ?
never : Key
}
interface Test {
foo: string
bar: number
baz: () => {}
}
194
Chapter 6 Advanced Usage
Great! Now we have a way to exclude functions. Let’s put the whole
thing together:
type JsonOf<T> =
T extends string | number | boolean ? T
: T extends Date | symbol ? string
: T extends Function ? null
: T extends object ? {
[Key in ValuesAsUnion<
ExtractKeysNotMatching<T, () => {}>
>]: JsonOf<T[Key]>
}
: T extends (infer Item)[] ? JsonOf<Item>
: never;
195
Chapter 6 Advanced Usage
Flatten
In our next advanced type, we want to represent the return type of the
following function:
Listing 6-18. Our target use case for our Flatten type
196
Chapter 6 Advanced Usage
The current built-in return type for Array.flat() loses the result order
for tuples, so we can also improve on this.
As a start, we know from the “Recursive Types” section in Chapter 5
that in order to transform an array or tuple, we need to deal with one
item at a time – by inferring the first (or “head”) element of the array,
transforming it, and then passing the remainder of the array into a
recursion of our own type. So we can start by writing the skeleton of our
type like this:
Listing 6-20. Recursively flatten the head element, and then repeat
for the remaining elements.
197
Chapter 6 Advanced Usage
UrlParameters
198
Chapter 6 Advanced Usage
For this type, we want to provide the parameter type for the
following method:
Listing 6-21. Our target use case for our UrlParameters type
Listing 6-22. Using a conditional type, we can infer the pieces of the
template literal and then use them to construct the result.
type SplitUrl<Url> =
// If Url is a string containing at least one '/', then
// infer anything
199
Chapter 6 Advanced Usage
Now, we can convert the result tuple into a union using an indexor and
then use Extract (the utility type, the opposite of Exclude) to get just those
keys that match another string template type:
Listing 6-23. Filter the extracted pieces of the URL pattern to only
those matching the pattern we’re using for URL parameters.
type Result = SplitUrl<'api/person/:personId/address/:addressId'
>[number];
❯
type Result = "api" | "person" | ":personId" | "address" |
":addressId"
type Result =
Extract<
SplitUrl<'api/person/:personId/address/:addressId'>
[number],
`:${string}`
>;
❯ type Result = ":personId" | ":addressId"
200
Chapter 6 Advanced Usage
Listing 6-24. Our final result is a record using the keys we extracted
for the URL parameters
201
Chapter 6 Advanced Usage
type Result =
Record<RequiredParameters, string>
& Partial<Record<OptionalParamters, string>>;
❯ type Result = {
personId: string
addressId?: string
}
202
Chapter 6 Advanced Usage
Listing 6-27. Collecting the required and the optional fields into
separate fields of a result type
type SplitUrlWithOptionals<Url> =
// We match for the '(abc/xyz)/Rest' template before its
// non-optional counterpart, as elsewise our match will
// assume that the () are part of the required
// parameter names:
Url extends `(${infer Head})/${infer Rest}`
? (
{
required: []
optional: SplitUrl<Head>
}
& SplitUrlWithOptionals<Rest>
)
// Again, match for the '(abc/xyz)' template before its
// non-optional counterpart:
: Url extends `(${infer Head})`
? {
required: []
optional: SplitUrl<Head>
}
: Url extends `${infer Head}/${infer Rest}`
? (
{
required: SplitUrl<Head>
optional: []
}
203
Chapter 6 Advanced Usage
& SplitUrlWithOptionals<Rest>
)
: Url extends string
? {
required: [Url]
optional: []
}
: never;
type ComputedIntersection = {
required: ['a'],
optional: ['b'],
} & {
required: ['y'],
optional: ['z']
}
204
Chapter 6 Advanced Usage
type SplitUrlWithOptionals<Url> =
Url extends `(${infer Head})/${infer Rest}`
// Instead of intersections, we deep-merge the results
? MergeUrlParamSplits<
{
required: []
optional: SplitUrl<Head>
},
SplitUrlWithOptionals<Rest>
>
: Url extends `(${infer Head})`
? {
required: []
optional: SplitUrl<Head>
}
: Url extends `${infer Head}/${infer Rest}`
// (here too):
? MergeUrlParamSplits<
205
Chapter 6 Advanced Usage
{
required: SplitUrl<Head>
optional: []
},
SplitUrlWithOptionals<Rest>
>
: Url extends string
? {
required: [Url]
optional: []
}
: never;
type Result =
SplitUrlWithOptionals<
"user/:userId/(dashboard/:dashboardId)/(:another)"
>;
❯ type Result = {
required: ["user", ":userId"];
optional: ["dashboard", ":dashboardId", ":another"];
}
That’s looking better. Now we apply the same technique with the
Extract utility type.
Listing 6-30. Applying our same Extract filter to include only the
URL parameters
type UrlData =
SplitUrlWithOptionals<"api/person/:personId/
(address/:addressId)">;
206
Chapter 6 Advanced Usage
type Result =
Record<Extract<UrlData['required'][number],
`:${string}`>, string>
& Partial<Record<Extract<UrlData['optional'][number],
`:${string}`>, string>>
❯ type P = {
":personId": string;
":addressId"?: string | undefined;
}
207
Chapter 6 Advanced Usage
❯ type Result = {
personId: string;
addressId?: string | undefined;
}
// & here's our original function again, but using our improved
// UrlParamsOf:
function interpolateUrl<const Url extends string>(
url: Url,
params: UrlParamsOf<Url>
): string {
// ...
}
Further Reading
To explore more advanced type construction, several libraries that are
worth exploring in this area are as follows:
• HOTScript (www.npmjs.com/package/hotscript)
• type-plus (www.npmjs.com/package/type-plus)
• type-fest (www.npmjs.com/package/type-fest)
• ts-toolbelt (www.npmjs.com/package/ts-toolbelt)
208
Chapter 6 Advanced Usage
Summary
Congratulations! By completing this chapter, you have mastered the use of
the full range of TypeScript’s type system. In walking through these step-
by-step advanced type tutorials, you have drawn from a powerful toolbox
of types and combined the results understandingly into your desired
outcomes. Now, equipped with this knowledge, you will be able to create
truly amazing results!
We started off this chapter with the IsEqual<A, B> utility type, which
allowed us to perform compile-time assertions similar to runtime tests.
By leveraging union types, pattern matching, and deferred types, we
successfully built a type that checks for exact type equality, providing a
valuable tool for the rest of this chapter and for your continued exploration
of TypeScript types.
209
Chapter 6 Advanced Usage
210
Chapter 6 Advanced Usage
may be useful in the future for URL-building cases; but much more so
it is an exercise of all we have covered in this book and a good check for
ourselves to know that we really have now mastered TypeScript types –
from beginning to end.
As you now continue your TypeScript journey, I again encourage
you to engage with the communities, exploring the advanced type
construction, and through them discover more libraries and tools that can
enhance your TypeScript development experience.
By mastering these advanced types and utilities, you have taken
significant strides in becoming a TypeScript expert. These tools will
empower you to write more maintainable, reliable, and expressive code,
ultimately making you a more effective TypeScript developer. I hope this
chapter has expanded your horizons and prepared you to face even more
complex type challenges with success!
211
CHAPTER 7
Performance
Introduction
We have mastered the language; now we must master the tool. As helpful
as TypeScript is, it also represents a step between writing your code and
running it in production. Large or complex code bases with numerous
dependencies and intricate control flow can take some time to analyze and
make this step slow.
Delays between writing code and running it are worth reducing.
Aside from having small cost implications of dev time and CI/CD running
expenses, there is a larger cost that is often overlooked: the human issue.
As humans, we love the creative process. Psychologically our desire
to be creative, productive individuals means we bias toward the things
we find most success in and err away from activity we experience
friction from. Unfortunately this means that if a build process is slow, we
subconsciously avoid doing it.
However, quality only occurs from feedback.
Static analysis helps raise quality by bringing large parts of the testing
feedback cycle back within the creation process; but this feedback cycle
is only of the code quality, not the quality of the UX flow or the suitability
of the final product in market. Avoiding refining complex code can lead
to it becoming even more complicated, or to it becoming less reliable.
And so, far beyond being a simple convenience, it is in fact neurologically
214
Chapter 7 Performance
const bob: {
firstName: string
lastName: string
} = getPerson('1');
const fred: {
firstName: string
lastName: string
} = getPerson('2');
215
Chapter 7 Performance
greet({
firstName: 'Bob',
lastName: 'Smith',
});
In the preceding code, TypeScript will compute the arg types for greet
and check if the “bob smith” variable can be passed in – and then it will do
it again inside the greet function when calling the renderName function!
So, as convenient as inline types are, the way to solve this is to always
name your types. Assigning the complex type to a type alias or interface,
TypeScript can cache and reuse the computation more easily, and in cases
like the aforementioned, it will instead bypass the check entirely.
Listing 7-3. Store anonymous inline types as type aliases, and then
reuse them to reduce recomputation
greet({
firstName: 'Bob',
lastName: 'Smith',
});
216
Chapter 7 Performance
217
Chapter 7 Performance
Listing 7-5. Replacing inferred types with explicit types helps both
the developer and the compiler
type SomethingComplexResult<X> =
X extends undefined ? undefined
: X extends (boolean | 'true' | 'false') ? true
: X extends (NumericString | number) ? AsNumber<X>
: never;
218
Chapter 7 Performance
const a = 1000;
if (subcomputation3(x)) {
return a + Number(x);
}
if (subcomputation4(x)) { // Plus the return type also helps
return Date(x); ❌ // detect when a return value
}
// doesn't match our expectations
throw new Error(x);
}
If you’re still not sold on reducing inferred types (and, let’s face it, they
are pretty handy for avoiding maintenance friction), there’s one last reason
for ensuring return types on complex functions. And it has to do with
future support for concurrency.
Take the following example code:
219
Chapter 7 Performance
case 'mode3':
return parser3(s); // calls parser1() or parser2()
// internally
}
}
To infer the return type, TypeScript has to process each of the parser()
functions. If these also have inferred return types and internally call
each other, then the processing gets harder and harder to parallelize
until essentially TypeScript has to resolve their return types sequentially
(Figure 7-1).
Instead, adding the return type means each function can be analyzed
in parallel, as the type contracts for the subfunctions are already available
(Figure 7-2).
220
Chapter 7 Performance
resolve parse
internal types
Parallelized execution
221
Chapter 7 Performance
type Foo<T> =
T extends string ? Concatenate<Some<Complex<Expensive
<Bar, T>>>, '-'>
: T extends number ? Concatenate<Some<Complex<Expensive
<Bar, T>>>, '.'>
: T;
type Foo<T> =
Some<Complex<Expensive<Bar, T>>>
extends infer PrecomputedT Store complex type in a
temporary type var
? (
T extends string ? Concatenate<PrecomputedT, '-'>
: T extends number ? Concatenate<PrecomputedT, '.'>
: T
)
: never;
222
Chapter 7 Performance
Reduce Intersections
Intersections are one of the more expensive computed types, because they
merge properties creating resultant properties with the common-
denominator subtype that satisfies both. In addition to this, TypeScript
also performs further checks when resolving a value as the type because
the value has to be checked against both properties, ending in slightly
more than double the amount of computation (or triple or more in the
case of multi-intersectional types).
Intersections are useful to reducing maintenance and when creating
complex types, but they can also add cognitive overhead, where the
property-merge is not really needed. Take the following example:
interface Employee {
employeeNumber: number
firstName: string
lastName: string
postcode: number
}
interface Person {
name: string
postcode: string
}
223
Chapter 7 Performance
224
Chapter 7 Performance
Interfaces are therefore often safer and faster, and have less cognitive
load, than intersection types. However, interfaces cannot extend types
defined by their generic parameters, because they need to resolve all fields
pre-compilation. So as a rule, use interfaces when combining known types,
and use intersection types only when you need the property-merge effect
and/or are combining types defined by generic parameters.
225
Chapter 7 Performance
// root/tsconfig.json:
{
"compilerOptions": {
"composite": true
},
"references": [
{ "path": './utils' },
{ "path": './app' },
]
}
// root/projects/utils/tsconfig.json:
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "../dist",
"declaration": true
},
226
Chapter 7 Performance
"include": [ "./src/**/*" ]
}
// root/projects/utils/src/index.ts
export function greet(name: string): string {
return `Hello ${name}!`;
}
// root/projects/app/tsconfig.json:
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "../dist"
},
"references": [
{ "path": "../utils" }
],
"include": [ "./src/**/*" ]
}
// root/projects/app/src/index.ts
import { greet } from 'utils';
When you build the app project, TypeScript will automatically build
the utils project first due to the project reference. The resulting JavaScript
files will be output to the specified dist folders. Changes in the app project
only will not require the utils project to be rebuilt.
227
Chapter 7 Performance
228
Chapter 7 Performance
Once the tsc process has completed, you will find it has created a
folder called ./trace-dir and stored two files inside it: trace.json and
types.json.
Traces are complex data blobs and need tools to view them. So let’s
look at two tools we can use to do this.
The tool will output a list of “hot spots” in your code where
compilation is taking more than average effort, as well as the time taken
for each spot. This should be enough for you to get started targeting your
performance optimizations.
229
Chapter 7 Performance
Hot Spots
├─ Check file \\demo\example-hot-spot.ts (3116ms)
│ ├─ Check variable declaration from (line 63, char 7) to
(line 93, char 2) (1641ms)
│ │ └─ Check expression from (line 63, char 23) to
(line 93, char 2) (1641ms)
│ │ └─ Check expression from (line 69, char 5) to
(line 92, char 6) (1610ms)
│ │ ├─ Check expression from (line 70, char 21) to
(line 80, char 10) (957ms)
│ │ └─ Check expression from (line 81, char 20) to
(line 91, char 10) (653ms)
│ ⋮
⋮
When the trace has loaded, it will show a flame graph of the build
process effort (y axis) against time (x axis). You can click on the root of
each flame to see which file was processed at each point in a view panel
at the bottom of the graph – choose the widest ones to find the highest
processing cost.
Clicking on the flame graph row segments below each file root will
show the effort given the code processed within that file (Figure 7-4).
Unfortunately, the quick-view panel for this is less helpful: the types are
only given ids (source type and target type), not names, so you’ll need to
locate the corresponding id in the types.json file to find the correlative
type name, compilation mode (union, object, etc.), and recursion depth.
It’s a slower process than using the NPM package, but it allows more fine-
grained analysis of what is happening during compilation.
Summary
In this chapter, we explored various techniques and best practices to
optimize the performance of TypeScript compilation. Compilation
performance is an important part of code build quality – if your code is a
pain to build, as a human, you’ll subconsciously avoid rebuilding it unless
necessary, and this will reduce the velocity of your build-measure-learn
cycle and the resultant quality of your code.
231
Chapter 7 Performance
232
Chapter 7 Performance
233
CHAPTER 8
Build
Introduction
In this chapter, we will cover the remaining aspects of the build process.
TypeScript project configuration is essential to this process, and the heart
of this configuration lies in the tsconfig.json file, located in the root
of your project folder. We’ll begin with an overview of the main options
available in this file. Understanding these options is crucial to successfully
harness the full potential of the TypeScript compiler.
We will then cover what I would recommend you use as a sensible
default for your tsconfig.json settings. Instead of overwhelming you
with an exhaustive list of every option available, I’ll highlight just the
most important ones that will enhance your projects’ code quality,
maintainability, and compatibility.
After mastering the TypeScript compiler options, we explore additional
tooling that can also help prevent errors available via linting. We will
explore ESLint and its TypeScript ESLint supplement and again review
what are a good go-to set of rules to leverage among the many available.
By using the tsconfig and ESLint configurations in this chapter, you will
have a robust tooling setup to catch the majority of common programming
mistakes and improve code quality.
Compiler Options
TypeScript project configuration is provided by the presence of a
tsconfig.json file in the root of the project folder. To create a boilerplate
of this file, use the following command:
236
Chapter 8 Build
237
Chapter 8 Build
{
"compilerOptions": {
// Targeting Modern Browsers:
"module": "ESNext",
"moduleResolution": "NodeNext",
"target": "ESNext",
"lib": [ "esnext" ],
238
Chapter 8 Build
239
Chapter 8 Build
"exclude": [
"node_modules"
]
}
240
Chapter 8 Build
241
Chapter 8 Build
243
Chapter 8 Build
244
Chapter 8 Build
245
Chapter 8 Build
Other Options
The only remaining options I would suggest considering would be the
performance-related options skipLibCheck, skipDefaultLibCheck, and
incremental, as well the diagnostic diagnostics/extendedDiagnostics
options. These are all covered in Chapter 7, in the “Other Performance
Tweaks” and “Debugging Performance Issues” sections.
246
Chapter 8 Build
Linting
Linting is most often used as a way to enforce code style decisions across
a team, or even just in your own code. But this isn’t the true extent of what
lint tools such as ESLint can bring to your development process.
Linting, simply put, is actually a static-analysis step that runs a suite of
rules on your code. In this light, the TypeScript type checks themselves can
be considered a lint ruleset. And using community-contributed tools such
as ESLint, we can extend those type checks to include additional safety
checks that haven’t officially been made part of TypeScript yet.
ESLint comes with a suite of rules that it recommends as best practice.
On top of these, the TypeScript team and the community have contributed
a further suite of rules in the supplementary NPM package. We will review
these in this chapter and list those ones that will assist with further type
assertions.
Installing ESLint
First, to install ESLint and its TypeScript ESLint supplement, run the
following command in the root of your project:
The rules available in these tools are provided through ESLint plug-ins.
To configure these, we now need to create a .eslintrc.cjs file in the root
of our project and with the following code:
247
Chapter 8 Build
// /.eslintrc.cjs:
/* eslint-env node */
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended'
],
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint'
],
root: true,
};
The preceding configuration will tell ESLint to use its own default
“recommended” suite of rules, as well as the “recommended” ones
provided in the typescript-eslint plug-in.
Ideal Ruleset
In many situations, devs will consider the aforementioned a sufficient
coverage. But some of the rules included in the recommended sets are
either better delivered by TypeScript itself or can be considered stylistic.
Others are not included in the recommended sets yet because of the
additional processing power they require, but are worth bringing into your
project if possible. So let’s edit your .eslintrc.cjs now to the following
more powerful setup:
248
Chapter 8 Build
/* eslint-env node */
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-
checking'
],
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
ecmaVersion: 2022,
sourceType: 'module',
createDefaultProgram: true,
ecmaFeatures: {
tsx: true,
},
},
plugins: [
'@typescript-eslint'
],
root: true,
rules: {
'@typescript-eslint/default-param-last': 'error',
'@typescript-eslint/no-array-constructor': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-interface': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-extra-non-null-
assertion': 'off',
249
Chapter 8 Build
'@typescript-eslint/no-extra-semi': 'off',
'@typescript-eslint/no-implied-eval': 'error',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-invalid-this': 'error',
'@typescript-eslint/no-loop-func': 'warn',
'@typescript-eslint/no-loss-of-precision': 'warn',
'@typescript-eslint/no-misused-new': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-redeclare': 'error',
'@typescript-eslint/no-shadow': [
'warn',
{ allow: ['_'] },
],
'@typescript-eslint/no-unnecessary-type-
assertion': 'warn',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-namespace-keyword': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'@typescript-eslint/require-array-sort-compare': [
'error',
{ ignoreStringArrays: true },
],
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/restrict-template-expressions': [
'error',
{
250
Chapter 8 Build
allowAny: false,
allowBoolean: true,
allowNullish: false,
allowNumber: true,
allowRegExp: false,
},
],
'constructor-super': 'off',
'for-direction': 'off',
'getter-return': 'off',
'guard-for-in': 'warn',
'no-async-promise-executor': 'off',
'no-await-in-loop': 'warn',
'no-caller': 'error',
'no-class-assign': 'off',
'no-compare-neg-zero': 'off',
'no-console': [
'warn',
{ allow: ['warn', 'error'] },
],
'no-const-assign': 'off',
'no-control-regex': 'off',
'no-delete-var': 'off',
'no-dupe-args': 'off',
'no-dupe-class-members': 'off',
'no-dupe-keys': 'off',
'no-eval': 'warn',
'no-floating-decimal': 'warn',
'no-func-assign': 'off',
'no-implicit-globals': 'warn',
'no-import-assign': 'off',
251
Chapter 8 Build
'no-iterator': 'error',
'no-labels': 'error',
'no-multi-str': 'warn',
'no-new-func': 'error',
'no-new-symbol': 'off',
'no-obj-calls': 'off',
'no-octal-escape': 'error',
'no-octal': 'error',
'no-param-reassign': 'error',
'no-proto': 'error',
'no-redeclare': 'off',
'no-return-assign': [
'warn',
'always',
],
'no-self-compare': 'error',
'no-sequences': 'error',
'no-setter-return': 'off',
'no-sparse-arrays': 'off',
'no-template-curly-in-string': 'error',
'no-this-before-super': 'off',
'no-throw-literal': 'warn',
'no-undef': 'off',
'no-unreachable': 'off',
'no-unused-vars': 'off',
'no-useless-call': 'warn',
'no-useless-rename': 'warn',
'no-var': 'error',
'no-warning-comments': 'warn',
'no-with': 'off',
'prefer-promise-reject-errors': 'warn',
252
Chapter 8 Build
'symbol-description': 'error',
'valid-typeof': 'off',
'vars-on-top': 'warn',
}
};
Phew! That’s a lot of settings. Let’s review these changes now and
discuss each.
253
Chapter 8 Build
254
Chapter 8 Build
255
Chapter 8 Build
256
Chapter 8 Build
257
Chapter 8 Build
258
Chapter 8 Build
259
Chapter 8 Build
260
Chapter 8 Build
R
emoved Rules
In addition to switching on some additional rules in the preceding config,
we also switched some of the “recommended” rules off. There are two
groups of reasons for these. For those in the typescript-eslint plug-in,
newer versions of TypeScript already provide safer checking than the
ruleset does, and so we no longer need them. And for those in the ESLint
config (further down in the following table), they are simply already
covered by existing TypeScript error checks and type safety, so they are
also no longer needed.
261
Chapter 8 Build
262
Chapter 8 Build
263
Chapter 8 Build
264
Chapter 8 Build
265
Chapter 8 Build
266
Chapter 8 Build
Further Rules
ESLint is a powerful tool to add to your build workflow, adding many
checks that are not yet part of the TypeScript type-checking analysis. It
represents an important aspect of your build process and should be part of
every TypeScript project to augment and protect against errors.
As you may have noticed, in all the cases we’ve discussed in this
section, we are focusing our lint checking on rules that prevent actual
errors in our code. This type of static analysis is important to reduce the
maintenance effort on your team. And if you wish to go further, additional
suites of these error-prevention rules are also available in the following
rulesets that ship with the typescript-eslint package:
• plugin:@typescript-eslint/strict
• plugin:@typescript-eslint/strict-type-checked
JSX/TSX
JSX, which stands for JavaScript XML, is an extension to the JavaScript
language. It allows the developer to write HTML-like syntax directly within
JavaScript code, combining the power of JavaScript and the flexibility of
267
Chapter 8 Build
HTML together, and is written in files with a .jsx file extension. TSX is
an extension of JSX, bringing TypeScript type checking to JSX, and so it is
written in files with a (you guessed it!) .tsx extension.
Here’s an example of the JSX/TSX syntax:
Most browsers and runtimes won’t be able to run this directly, and so
during build, this is actually transpiled to the following:
268
Chapter 8 Build
interface MyComponentProps {}
269
Chapter 8 Build
Listing 8-10. How the TSX _jsx runtime function works internally
You may also notice in the preceding example that the props being
passed in are an object, and any JSX children components are passed in
via a property automatically named children by the transpiler.
Because we now know that all JSX components are simple function
calls, then of course we can also define our own properties alongside the
automatic children property:
270
Chapter 8 Build
const element =
<MyComponent imageSrc="assets/greeting.png">
Hello, world!
</MyComponent>
And we can even redefine the type of the children property and
require only a specific type of children:
interface MyComponentProps {
imageSrc: string
children: ReactElement<AnimalComponentProps>[]
}
271
Chapter 8 Build
{props.children}
</div>
)
}
const element =
<MyComponent imageSrc="assets/animals.png">
<AnimalComponent name="Garfield" type="mammal" />
<AnimalComponent name="Nemo" type="fish" />
<AnimalComponent name="Tweetie" type="bird" />
</MyComponent>;
272
Chapter 8 Build Chapter 8 Build
const element =
<MyComponent imageSrc="assets/animals.png">
{({ greetingName }) =>
Hello, {greetingName}!
<AnimalComponent name="Garfield"
type="mammal" />
<AnimalComponent name="Nemo" type="fish" />
<AnimalComponent name="Tweetie" type="bird" />
}
</MyComponent>;
interface MyListProps<Item> {
items: Item[]
onPress: (item: Item) => void
}
273
Chapter 8 Build
<MyList<number>
items={items}
onPress={onPressHandler}
/>;
Note Notice that I’ve used a “,” after the generic parameter. This is
a handy trick to help TypeScript know that the generic parameter list
is not to be confused with a JSX/TSX element.
Modules
Module Types Explained
Prior to ECMAScript Modules (ESM), modules were just conventions
around closures in JavaScript files. As outlined in this book’s introduction,
these evolved from emerging needs over the course of time, and
TypeScript has added output support for each of these as they were
released. However, as the industry has grown, and additional techniques
such as bundling, chunking, and zero-js delivery have emerged, the
TypeScript team’s development has necessarily focused more on type
checking, the core team instead contributing to Babel’s TypeScript “preset”,
in order to not lag on the pace of change. However, it is still worth having
a basic appreciation of these underlying module types, as these are still
part of the module resolution process and therefore part of TypeScript
compilation and present in the tsconfig.json options.
To demonstrate, take the following example snippet of TypeScript
code. In each following section, we will review the same code transpiled
by TypeScript to each module system, so you can see how the closure
conventions work in each case:
274
Chapter 8 Build
CommonJS (CJS)
Created in 2009 to solve the lack of a native ECMAScript module system at that
time and adopted by the Node.js core team while still in its infancy. This format
is still supported by many bundlers (e.g., Webpack, Rollup), but gradually
the industry is moving away from. It defines a global var (var without a var
keyword) called exports and attaches the module as a field within this var.
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createUrl = void 0;
const node_url_1 = require("node:url");
function createUrl(href) {
return new node_url_1.URL(href);
}
exports.createUrl = createUrl;
Listing 8-17. Code emitted from Listing 8-15 when targeting AMD
Listing 8-18. Code emitted from Listing 8-15 when targeting UMD
(function (factory) {
if (typeof module === "object" && typeof module.exports ===
"object") {
var v = factory(require, exports);
if (v !== undefined) module.exports = v;
}
else if (typeof define === "function" && define.amd) {
define(["require", "exports", "node:url"], factory);
}
})(function (require, exports) {
276
Chapter 8 Build
"use strict";
Object.defineProperty(exports, "__esModule", { value:
true });
exports.createUrl = void 0;
const node_url_1 = require("node:url");
function createUrl(href) {
return new node_url_1.URL(href);
}
exports.createUrl = createUrl;
});
S
ystemJS
Not actually a module system but included here to avoid confusion when
referencing tsconfig.json module options, SystemJS is actually a module
loader that (like RequireJS) has a particular syntax and so required a
specific module output format – and like RequireJS, it used a named
function to manage asynchronous load and the modules, although it used
a return value rather than a global var. This, as well as AMD and UMD, is
now largely deprecated by the JavaScript community in favor of the ESM
native module format.
277
Chapter 8 Build
exports_1("createUrl", createUrl);
return {
setters: [
function (node_url_1_1) {
node_url_1 = node_url_1_1;
}
],
execute: function () {
}
};
});
E SM
ES modules, finally standardized in 2015, are the native implementation
of modules. As TypeScript follows ECMAScript, the syntax for this is
essentially identical (without the type notations) to what you will already
be familiar with in TypeScript.
Listing 8-20. Code emitted from Listing 8-15 when targeting ESM
278
Chapter 8 Build
The ESM file format is defined by the use of the import and export
keywords. Any file that does not either import or export a value or type is
inferred to be a classic JavaScript file, and the values and types therein are
added to the global scope. This means they are available in all files without
additional import.
Once you include either an import or export, the game changes. Your
file is processed instead as an ESM module, and any values and types
therein are only available to other values and types within the same file. To
use them in other files (modules), you need to first export them from your
file and then import them into the desired consuming module.
To export a value or type, just prefix the value or type with the export
keyword:
// Export a function:
export function greet(name: string) {
return `Hello ${name}!`;
}
// Export an interface:
export interface Person {
firstName: string
lastName: string
}
279
Chapter 8 Build
interface Person {
firstName: string
lastName: string
}
280
Chapter 8 Build
Like when exporting, you can optionally add the type keyword as an
optimization hint to the TypeScript compiler to help save it a few steps
during compilation when working out whether to exclude imports from
emitted code:
The imports covered so far are performed when the module is loaded.
But if instead you want to conditionally import a module based on runtime
logic, you can also import it via an inline import:
281
Chapter 8 Build
282
Chapter 8 Build
283
Chapter 8 Build
Resolving:
❯ 'my-custom-package' is not a relative module path ❌
❯ 'my-custom-package' is not in path patterns in
tsconfig.json ❌
❯ '/src/packages/myPackage/node_modules' exists ✔
❯ '/src/packages/myPackage/node_modules/my-custom-package'
does not exist ❌
❯ '/src/packages/node_modules' does not exist ❌
284
Chapter 8 Build
285
Chapter 8 Build
S
ummary
In this chapter, you learned how the key aspects of TypeScript’s build
process work and how you can configure them to your advantage. We
explored configuration and extending it with linting, as well as JSX syntax
and its relation to standard JavaScript/TypeScript; and we dug deep into
module formats and how imports, exports, and module resolution work.
To recap, we started off with TypeScript’s powerful build configurations
and the core configuration for this: the tsconfig.json file. We started by
exploring compiler options, which allow you to control how TypeScript
compiles your code. In this section, I presented a recommended suite of
settings that were tailored for modern development and with a focus on
ensuring code quality and maintainability.
We then also covered how to improve even further on the static test
provided by TypeScript by using a complementary suite of quality-focused
ESLint rules. We discussed the rules I propose as a sensible default for all
projects and the reasoning behind each. These rules were unopinionated
in that they were focused on error prevention rather than stylistic
assertions, as it is best to decide stylistic rules in the context of your team’s
own preferences. Using this recommended lint ruleset provides additional
safety checks that minimize potential issues.
Next we explored the JSX/TSX syntax and discovered how it is just
syntactic sugar atop our existing JavaScript runtime. As such, we explored
how we can use the TypeScript type-safety techniques learned in this book
and apply them to JSX/TSX files too. This means you can use advanced
types, such as computed return values, union type props, intersections,
and more in your React/TSX code to provide a high degree of type safety as
well as improve the autocomplete suggestions in your IDE.
Finally, we dived into modules – what they were, their formats, and
how TypeScript works with them. Understanding the ESM format, imports,
exports, and module resolution strategies enables you to organize your
code effectively and utilize TypeScript’s module system to its fullest
286
Chapter 8 Build
287
CHAPTER 9
Wrap Up
Congratulations, dear reader! By reaching this point, you will have gained
full mastery of this powerful tooling that will revolutionize your coding
journey. You are now equipped with the insights and expertise needed
to craft scalable and maintainable projects, even in the most complex
scenarios.
Your journey through this book has covered a wide landscape. We have
explored the roots of TypeScript and the imperative needs it fulfills within
the evolving JavaScript community. We have embraced the core concepts
of structural typing and how this interacts with a functional but dynamic
approach to coding. We have delved into advanced concepts, such as
advanced type inference, type widening and narrowing, type assertions,
and parameterized types. We have covered computed types, such as
unions, intersections, generics, and mapped and recursive types. And we
have seen how all these can be used together to create the built-in utility
types, improvements on them, and even more advanced types that take
advantage of the power of structural and dynamic typing.
Beyond types, you’ve also become an expert now in setting up optimal
TypeScript projects. You understand the configuration options in depth,
as well as how to extend these using additional rules from ESLint. We have
also looked at a number of the most cutting-edge TypeScript packages
available and explored how some of them can even further your journey.
290
Index
A B
Abstract classes and Babel, 246, 274
methods, 88, 98 Browserlist, 240
abstract keyword, 88 Bun tool, 15
Access modifiers, 81, 82,
84, 85, 87
Anonymous types, 104, 105, 215 C
Any type, 67, 68, 262 Caching types, 14, 160, 214,
Arrays, 28, 33–37, 142, 221–222, 232
156, 175, 180 Chromium-based browser, 230
function values, 194 Civet, 15, 16, 209
of globs, 246 Classes
JsonOf, 193 access modifiers, 82
optional fields, 202 constructors, 82
tuple, 197 ECMAScript, 82
as keyword, 50–52, 171, 281 fields, 83–86
Assertion functional programming
compiletime, 49–52, 55, 78, techniques, 81
182, 209 getters and setters, 86
runtime, 24, 49, 59–64, implements keyword, 89–92
67, 127 inheritance, 87–89
Assignments, 260 methods, 87
Async, 37, 39, 78, 258 object-oriented programming, 81
Asynchronous Module Definition structuring code, 81
(AMD), 4, 275, 276 warning
Auto-narrowing, 45–48 not types, 93–95
await, 37, 258 scope bleed problem, 95–98
292
INDEX
293
INDEX
294
INDEX
295
INDEX
296
INDEX
297
INDEX
298
INDEX
299