Clean Code With TypeScript (TRUE PDF)
Clean Code With TypeScript (TRUE PDF)
Rukevwe Ojigbo
Dr. Sanjay Krishna Anbalagan
Clean Code with TypeScript
Copyright © 2026 Packt Publishing
All rights reserved. No part of this book may be reproduced, stored in a retrieval system, or transmitted in
any form or by any means, without the prior written permission of the publisher, except in the case of
brief quotations embedded in critical articles or reviews.
Every effort has been made in the preparation of this book to ensure the accuracy of the information
presented. However, the information contained in this book is sold without warranty, either express or
implied. Neither the authors, nor Packt Publishing or its dealers and distributors, will be held liable for
any damages caused or alleged to have been caused directly or indirectly by this book.
Packt Publishing has endeavored to provide trademark information about all of the companies and
products mentioned in this book by the appropriate use of capitals. However, Packt Publishing cannot
guarantee the accuracy of this information.
Portfolio Director: Ashwin Nair
Relationship Lead: Sneha Shinde
Project Manager: Vishnu Priya R
Content Engineer: Nithya Sadanandan
Technical Editor: Arjun Varma
Copy Editor: Safis Editing
Indexer: Hemangini Bari
Proofreader: Nithya Sadanandan
Production Designer: Alishon Falcon
Growth Lead: Sohini Ghosh
First published: March 2026
Production reference: 1130326
Published by Packt Publishing Ltd.
Grosvenor House
11 St Paul's Square
Birmingham
B3 1RB, UK.
ISBN 978-1-83588-956-5
[Link]
To my mother, whose love, strength, and unwavering belief in me made this journey possible.
– Rukevwe Ojigbo
To my parents, mentors, teachers, and thinkers who shaped the way I understand systems,
structure, and learning—this small contribution stands on the foundation you built.
To my wife, whose constant support and quiet strength inspire me to give back more to society, just
as she gives so selflessly to our family every day.
And to my daughter, whose journey of learning to understand and respond to the world showed
me what true intelligence looks like. Watching her grow changed how I think about learning itself
—and ultimately how I build AI systems.
Index 399
Preface
TypeScript has become a cornerstone of modern web development, helping teams build
applications that are safer, easier to maintain, and more scalable as requirements evolve. By
adding strong typing and powerful tooling to JavaScript, TypeScript enables developers to
catch errors earlier, communicate intent more clearly, and write code that remains reliable
even as projects grow in complexity.
In this book, you will progress from TypeScript fundamentals to real-world development
practices that reflect how professional teams build and maintain production systems. You will
begin by learning how to set up TypeScript projects, understand essential and advanced types,
and configure TypeScript effectively. From there, you will focus on writing clean functions,
applying object-oriented programming principles, and organizing projects using modular,
scalable structures supported by consistent tooling.
As you advance, you will learn how to test TypeScript applications with unit and integration
tests, apply Test-Driven Development (TDD), and strengthen your applications through
better error handling, debugging techniques, and security best practices. You will also explore
performance optimization strategies and learn how design patterns can improve flexibility and
maintainability in real-world code bases.
The later chapters take a hands-on, production-focused approach, guiding you through setting
up scalable TypeScript environments using modern workflows such as Nx monorepos, pnpm
dependency management, and automated code quality enforcement with Git hooks. You will
then build a type-safe full stack application using NestJS and React, applying shared contracts
to keep frontend and backend aligned. Finally, you will follow a realistic narrative where a
working proof of concept evolves under changing requirements, learning how TypeScript helps
prevent contract drift, protect runtime boundaries, and intentionally move failures earlier in
the development lifecycle.
By the end of this book, you will have the skills to write clean, maintainable TypeScript code,
architect scalable projects, and confidently apply TypeScript in real-world systems that grow,
change, and evolve over time.
Preface xxii
Conventions used
There are a number of text conventions used throughout this book.
CodeInText: Indicates code words in text, database table names, folder names, filenames, file
extensions, pathnames, dummy URLs, user input, and Twitter handles. For example: "For
instance, a class named ShoppingCart provides much more context than simply Cart."
A block of code is set as follows:
/**
* Completes the checkout process.
* @param discount The discount percentage to apply.
* @returns The final total after applying the discount.
*/
checkout(discount: number): number {
const total = [Link](discount);
[Link]('Your total is: $' + [Link](2));
[Link]('Thank you for shopping with us!');
return total;
}
Preface xxvi
When we wish to draw your attention to a particular part of a code block, the relevant lines or
items are set in bold:
/**
* Completes the checkout process.
* @param discount The discount percentage to apply.
* @returns The final total after applying the discount.
*/
checkout(discount: number): number {
const total = [Link](discount);
[Link]('Your total is: $' + [Link](2));
[Link]('Thank you for shopping with us!');
return total;
}
Bold: Indicates a new term, an important word, or words that you see on the screen. For
instance, words in menus or dialog boxes appear in the text like this. For example: "To follow
along with the examples in this chapter on testing and TDD, you will need [Link] version
20.0.0 or later and TypeScript version 5.0 or later installed on your machine."
Note
Warnings or important notes appear like this.
Tip
Tips and tricks appear like this.
Get in touch
Feedback from our readers is always welcome.
General feedback: If you have questions about any aspect of this book or have any general
feedback, please email us at customercare@[Link] and mention the book's title in the
subject of your message.
Errata: Although we have taken every care to ensure the accuracy of our content, mistakes do
happen. If you have found a mistake in this book, we would be grateful if you reported this to
xxvii Preface
us. Please visit [Link] click Submit Errata, and fill in the
form.
Piracy: If you come across any illegal copies of our works in any form on the internet, we would
be grateful if you would provide us with the location address or website name. Please contact
us at copyright@[Link] with a link to the material.
If you are interested in becoming an author: If there is a topic that you have expertise in and
you are interested in either writing or contributing to a book, please visit http://
[Link]/.
Preface xxviii
How to unlock
Scan the QR code (or go to [Link]/unlock). Search for this book by name, confirm the
edition, and then follow the steps on the page.
Note: Have your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
[Link]
Your review is important to us and the tech community and will help us make sure we're
delivering excellent quality content.
1
Introducing TypeScript
In the ever-evolving world of web development, TypeScript has emerged as a powerful tool
idx_db64b241
for creating robust, maintainable, and error-free code. In this chapter, we will explore the
benefits of using TypeScript in modern web development and provide practical examples of
how it can be used to enhance code quality, readability, and maintainability.
We will begin by discussing the rationale for using TypeScript and identifying scenarios where
it can be most effective. Next, we will provide a step-by-step guide to setting up a basic
TypeScript project and installing TypeScript. We will then dive into the foundational types of
TypeScript, including strings, numbers, and Booleans, and provide practical examples of how
they can be used in real-world scenarios. We will also explore more complex types, such as
arrays and tuples, and discuss how they can be used to manipulate data in TypeScript.
Additionally, we will cover the use of enums for better code readability. Finally, we will
introduce advanced types, such as union types and intersection types, and explain how they
can be used to create dynamic and adaptable types.
As part of our comprehensive coverage, we'll walk through the most crucial aspects of the
[Link] file, providing a clear understanding of its configuration options.
By the end of this chapter, you will have a solid understanding of TypeScript and will be able to
use it to write more efficient and maintainable code.
In this chapter, we're going to cover the following main topics:
• Why TypeScript
• Getting started with TypeScript
• Understanding basic types
• Working with complex types
Chapter 1 2
Technical requirements
To maximize your engagement with this chapter and actively participate in the hands-on
exercises, ensure that you have the following technical requirements:
• A basic understanding of the JavaScript programming language.
• [Link] and npm: [Link] must be installed on your system to execute the TypeScript
compiler (tsc). You can download and install [Link] from the official website:
[Link]
With the technical prerequisites in place, you're now ready to dive into the core concepts of
TypeScript. But before we begin, it's important to understand why TypeScript has become
such a popular choice among developers. In the next section, we'll explore the key advantages
of using TypeScript and how it enhances the development process.
3 Introducing TypeScript
Why TypeScript?
JavaScript was originally designed as a lightweight scripting language for small browser
idx_8a89b6c2
interactions such as handling button clicks, validating forms, or adding simple UI behavior. For
many years, it was never intended to manage large, long-lived applications.
As web applications grew richer and more complex, more logic moved into the browser and
server-side JavaScript environments. Code bases expanded from a few scripts into hundreds of
interconnected files maintained by large teams. This growth exposed several challenges:
runtime errors caused by unexpected data, difficulty understanding and refactoring code
safely, and limited tooling support for reasoning about large systems.
At the core of these problems was a fundamental limitation: JavaScript provides no built-in
idx_463a3eba
way to verify correctness before code runs. Errors are often discovered only at runtime,
sometimes long after deployment.
TypeScript was created to address this gap. By adding optional static typing and improved
tooling to JavaScript, TypeScript enables developers to catch errors earlier, reason about code
more effectively, and scale applications with greater confidence.
variables, function parameters, return values, classes, and namespaces. Not only do we benefit
from enhanced code readability by being able to explicitly define these types, but runtime
errors are reduced, and potential bugs are caught during the development phase itself. Real-
world applications benefit from fewer unexpected runtime errors, leading to more reliable and
stable software.
In collaborative development environments, TypeScript's type annotations serve as
documentation for developers working on the same code base. Type definitions clarify the
expected input and output of functions, reducing confusion and improving code
comprehension. As projects grow, maintaining and extending code becomes more manageable
due to the additional layer of information provided by TypeScript.
When refactoring or modifying existing code, TypeScript provides a safety net by identifying
places in the code base that need to be updated due to changes in types or signatures. This
minimizes the risk of introducing bugs during refactoring and encourages developers to make
changes without fear of breaking existing functionality.
Chapter 1 4
React, and [Link]. By adding static type checking to these frameworks, TypeScript helps
developers build more reliable and maintainable applications when creating complex user
interfaces, managing state, and handling data flow. TypeScript's type system provides
structure and catches errors early in the development process, making frontend applications
more scalable and easier to maintain.
TypeScript is not limited to frontend development. It's also gaining traction in backend
development using technologies such as [Link] and Deno. The ability to define types and
idx_cc505e7f
interfaces makes working with APIs and databases more intuitive and error-resistant.
TypeScript's type annotations ensure that requests and responses adhere to defined structures
when building middleware or RESTful APIs using frameworks such as [Link], resulting in
less error-prone code and improved API documentation. Third-party libraries and modules can
also benefit from TypeScript's type system, with type definitions for popular libraries available
on DefinitelyTyped. The DefinitelyTyped repository provides type definitions for popular
idx_8101db71
libraries, bridging the gap between untyped JavaScript and type-safe TypeScript projects. You
can find the repository here: [Link]
Now that we have explored TypeScript, its rationale, and the benefits it provides, the upcoming
section will guide you through the process of setting up and configuring a TypeScript project,
offering a step-by-step approach for seamless implementation.
5 Introducing TypeScript
This will install tsc globally, allowing you to access it from anywhere on your system.
Congratulations, you just succeeded in installing TypeScript. Now, we can proceed with setting
up our first TypeScript project.
idx_c9a61a01
$ npm init –y
Chapter 1 6
This command generates a [Link] file with default settings. The -y flag
idx_9c6bc267
automatically selects default settings, skipping the interactive setup process. You can
customize these settings as needed by editing the [Link] file later. Your
directory should look like Figure 1.1.
If you installed TypeScript globally on your machine, then it's possible to skip this step
if you are working on something basic, but it's great to do it on a project where you are
collaborating with others, so that you can have the same versions, for example.
4. Create a [Link] file by using the following command:
The [Link] file contains the configuration for your TypeScript project. The
idx_dbb3a8d2
preceding command should generate a file like the one in the following figure:
5. Create your first TypeScript file and name it [Link]. Open a text editor and add the
following content:
$ [Link]("Hello, TypeScript!");
6. To compile your TypeScript code to JavaScript, run the following command in the
terminal:
$ npx tsc
This will generate a JavaScript file called [Link] in the same directory, which can be
run using [Link]:
$ node [Link]
You should notice the message "Hello, TypeScript!" printed to the console.
Chapter 1 8
Figure 1.3 — Result of creating and compiling the first TypeScript file ([Link])
Fantastic! We've created our first TypeScript project and compiled the code into JavaScript.
idx_3088bcd7
Now, we'll continue our adventure. Before we proceed, let's discuss the [Link] file.
While we'll explore it in depth later, it's helpful to understand some of its default settings now.
Let's break down the configuration options in the [Link] file:
idx_f609594f
• target: This specifies the ECMAScript version that TypeScript should aim to compile to.
In this instance, we are targeting ES5, which is the fifth major version of JavaScript
released in 2009. This means that TypeScript will generate JavaScript code compatible
with most web browsers and [Link] installations.
• module: This specifies the module format for the generated JavaScript code. In this case,
the module format is set to commonjs, which is a module format used by [Link].
9 Introducing TypeScript
• strict: This activates strict mode, which is a set of rules that helps to improve the
security and maintainability of TypeScript code. For instance, strict mode will
prevent you from using undeclared variables or failing to close parentheses correctly.
• esModuleInterop: This enables compatibility with modern JavaScript features, such as
the import and export keywords. This option is crucial for TypeScript to work with
JavaScript libraries that utilize these newer features.
• skipLibCheck: This informs TypeScript to skip checking the type definitions for the
TypeScript library. This is a beneficial option if you are employing a custom version of
the TypeScript library. It instructs the TypeScript compiler to bypass type checking for
idx_1775c64c
the TypeScript library itself (the [Link] file). This can speed up compilation times,
especially in large projects with many dependencies. skipLibCheck should be used
with care, as it potentially conceals errors within the TypeScript library or its
interaction with your code.
• allowJs: This permits the use of JavaScript files in your TypeScript project. This can be
useful if you need to include code written in JavaScript or if you want to gradually
migrate your project to TypeScript.
idx_52a48ffc
Now that our TypeScript project is set up, let's delve deeper. In the next section, we'll explore
basic types in TypeScript and begin to understand how to use them.
representations. They establish a shared understanding between you, the developer, and the
TypeScript compiler, ensuring clarity and precision within your code.
Throughout this section, you will gain insights into the basic types in TypeScript, how to
effectively utilize them in your code, and discover practical examples and use cases for each
type.
examples:
• String type:
Practical example/use case: Used to represent text data, such as names, addresses,
and messages
• Number type:
Practical example/use case: Used to represent numeric data, such as ages, prices, and
quantities
• Boolean type:
Practical example/use case: Used to represent logical data, such as whether a user is
logged in or not
• Null type:
Practical example/use case: Used to represent a variable that has not been assigned a
value
• Symbol type:
Practical example/use case: Used to represent a unique identifier, such as object keys
We've just listed common basic types in TypeScript. If you choose one of these variables, such
as the fullName variable, and attempt to reassign it to a different type that doesn't match the
expected type, TypeScript will show an error. For instance, in the following figure, we've tried
to reassign the fullName variable to a number on line 4:
Figure 1.4 —The squiggly red line under the fullName variable indicates a TypeScript error
If you look closely at line 3, where the reassignment is happening, you will notice the red line. If
idx_5a9f0198
you hover on the red line, you will notice what the issue is:
Figure 1.5 — The error details when you hover over the fullName variable
While this example may appear trivial, the implications become crucial in real-world
scenarios. Imagine building a financial application in plain JavaScript where precise data types
Chapter 1 12
are essential for mathematical operations. In such scenarios, TypeScript's type checking
catches potential type errors early, reducing the risk of bugs, such as unintended arithmetic
operations between incompatible types (e.g., a string and a number).
Arrays
Think of a shopping list, a sequence of items you need to buy. In TypeScript, arrays provide
similar functionality, serving as ordered collections of values. Just like your list items, each
element within an array can be accessed using its index position.
In the following example, we declare a shoppingList array with a type annotation specifying
that it should contain strings. We initialize the array with some initial items – "Apples",
"Bananas", and "Milk":
If we attempt to add a number (123) into the shoppingList array using the push method, we
get a TypeScript error:
In a previous section (on types and interfaces), we discussed objects and types. As an example,
we defined the Person type:
type Person = {
name: string;
age: number;
greet: () => void;
};
Now, let's combine this Person type with an array to create a list of individuals, each
represented by an object. For instance, we'll include two people, Alice and Bob:
},
},
{
name: "Bob",
age: 25,
greet() {
[Link](
`Hello, my name is ${[Link]} and I'm ${[Link]} years old.`);
},
},
];
In this example, we're already assembling these building blocks to create more robust and
type-safe code. If you were to add a value to the persons array that doesn't conform to the
structure defined by the Person type, TypeScript would raise an error.
As demonstrated earlier, we continue to reap the benefits of type inference. For instance, if we
attempt to add a new person object to our list, TypeScript provides a helpful hint in our code
editor regarding the expected property types. Take a look at the following figure:
Figure 1.9 — Code editor displaying type hints for the expected properties
Type inference
In the examples we've just seen regarding basic types, we explicitly specified the type of the
idx_42fa6d20
variable. However, TypeScript's type system can infer types automatically. For instance, if we
use the fullName variable again without explicitly specifying its type, TypeScript will still
complain if we attempt to reassign the variable to a value of the number type:
Chapter 1 14
Figure 1.6 — TypeScript inferring types, even when they are not explicitly stated
Now that we've explored the basics of TypeScript types, let's delve into more complex types in
idx_2e00e7d8
data representation extends beyond simple types such as strings and numbers. This section
delves into objects, arrays, tuples, and enums, empowering you to structure and represent
more complex data collections and enhance code readability.
We will begin with objects.
Objects
Objects in TypeScript allow you to encapsulate related data and functionality into a single
idx_85d885c0
entity. You can define object properties and methods, providing a clear structure to your code.
In the following code snippet, we have created a person object:
We have specified the expected structure of our object (the expected type). Let's see what
happens when I remove one of the properties. Refer to the code snippet in the following figure;
I have omitted the age property:
15 Introducing TypeScript
Figure 1.7 — TypeScript error when the object contains incomplete details
You will notice the red line under the person variable. If we hover over that line, we see more
idx_088c7596
details about what the issue is. To get a better understanding, refer to the following figure:
Figure 1.8 — TypeScript shows error details when we hover over the person object
TypeScript once again is coming in handy, highlighting errors and giving us immediate
feedback.
Types/interfaces
In the previous example, we defined the structure of an object (type) in the same line as our
idx_197a8cc5
variable declaration. However, for improved reusability and organization, it's advantageous to
extract the type definition into a separate block:
type Person = {
name: string;
age: number;
greet: () => void
}
Chapter 1 16
And then we can use the defined type with the object as follows:
This approach is beneficial because it enables code reuse. You can combine types to form even
more complex types and reuse them across different locations in your application.
idx_a14798af
Interfaces in TypeScript serve a similar purpose to types but with additional capabilities,
allowing you to define contracts for object shapes and behavior. We'll delve into interfaces in
more detail later, exploring their nuances and practical applications.
Tuples
Tuples are similar to arrays, but have a fixed, predefined length and specific types for each
idx_fa8c0edf
element.
Imagine modeling a product with an ID, name, and price:
You can access this as a normal array, so for example, if I wanted to see the price (third element
idx_96a8d145
[Link](product[2]);
You can see in your console or terminal that the output is as follows:
19.99;
17 Introducing TypeScript
But I declared a different variable, say invalidProducts, and specified its type as Product:
The preceding code results in a type error. See the following figure for details:
Figure 1.10 — Code editor displaying type hints for the expected properties
Enums
Enums allow you to define named constants for a set of related values with a clear, predefined
idx_632a2000
list of choices. This structure makes code more consistent and reduces the chance of invalid
values.
Let's start with a simple example to demonstrate how enums work:
enum Direction {
Up,
Down,
Left,
Right
}
In this example, we define a Direction enum with four members: Up, Down, Left, and Right. By
default, Up is assigned the value of 0, Down is 1, Left is 2, and Right is 3. We then assign
Chapter 1 18
[Link] to a variable, playerDirection, and log its value, which outputs 0, indicating
idx_97b3d0da
enum ErrorCode {
NotFound = 404,
Unauthorized = 401,
InternalServerError = 500
}
In this example, we define an ErrorCode enum with three members: NotFound, Unauthorized,
and InternalServerError. We assign custom HTTP status code values to each member. When
we assign [Link] to the error variable, it holds the value of 404.
Enums are especially useful when defining functions that operate on a fixed set of values. Here
is an example:
enum DayOfWeek {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
}
In this example, we define a DayOfWeek enum representing the days of the week. We then
define an isWeekend function that takes a DayOfWeek parameter and returns true if it is a
19 Introducing TypeScript
weekend (Saturday or Sunday), and false otherwise. Finally, we call the function with
[Link], which outputs true.
Enums in TypeScript provide a powerful way to represent fixed sets of values. By utilizing
idx_2a03df7b
enums effectively, you can make your code more expressive and self-documenting, leading to
better overall code quality. Experiment with enums in your TypeScript projects to see how they
can simplify your development process.
In this section, we've broadened our TypeScript skills by diving into objects, arrays, tuples, and
enums, enhancing our ability to handle complex data. Moving forward, we'll explore advanced
types such as unions, intersections, generics, and conditionals, further refining our coding
capabilities.
about powerful features such as union types, intersection types, generics, and conditional
idx_9bedb5e6
types. These advanced types enable you to create more flexible, dynamic, and adaptable type
definitions in your TypeScript code. By mastering these concepts, you'll be equipped to handle
complex scenarios and write more robust and maintainable code. Let's begin.
particularly useful when a function or variable can accept different types of values. Imagine
representing user input that could be a string or a number:
In the preceding code, we have defined a type called Input that can be either a string or a
number.
We have a function that accepts this input and performs an operation depending on the value
passed to the function.
Chapter 1 20
processInput("hello");
The preceding call prints the capitalized string "HELLO" to the console.
Let's now call the processInput function with a number, as follows:
processInput(3.14121);
The preceding call prints the output to two decimal places: 3.14.
idx_6b4cd7f1
Union types allow for flexible data handling and prevent runtime errors by ensuring that the
assigned value matches one of the allowed types.
single, cohesive type. Imagine combining the properties and methods from different sources to
create a more expressive type. Let's explore this concept using an example related to an
organization's employees.
Suppose you're building a simple application to manage an organization's employees. Each
employee has specific properties such as name, age, and a unique badge number. Additionally,
they can greet others with a custom message.
Initially, you might define an Employee type directly, including all the necessary properties:
type Employee = {
name: string;
age: number;
greet: () => void;
badgeNumber: number;
};
However, if you look closely, some of these properties are quite familiar. The name, age, and
greet properties are essentially the same as those in a Person type. Instead of duplicating
ourselves, let's create a more elegant solution.
21 Introducing TypeScript
We'll create an EmployeeBase type that contains only the badgeNumber property:
type EmployeeBase = {
badgeNumber: number;
};
Now, the magic happens. We'll combine the existing Person type with our EmployeeBase type
using an intersection type:
The resulting Employee type inherits all properties from both the Person type and
EmployeeBase. It's like merging two worlds into one!
By using intersection types, we avoid redundancy and keep our code DRY, which stands for
don't repeat yourself. We express relationships between types more precisely, making our
code cleaner and more maintainable.
with different data types. They provide flexibility and reusability by allowing types to be
specified dynamically. Let's see some applications of generics.
Chapter 1 22
Applications of generics
One of the primary uses of generics in TypeScript is to make functions more flexible and
idx_e3d8a74f
reusable. Generics allow functions to handle multiple types without needing to rewrite the
same function for each type. Let's explore this with an example.
Consider a function that reverses an array. You want this function to work with any type of
array (for example, string or numbers) without having to rewrite the function for each type.
Here's how you can achieve this with generics:
Now, let's put our function to the test. How do we do that? See the following figure:
idx_c61be7bf
Let's go over each part of the code in detail and see what's happening.
Firstly, we invoke the reverseArray function and specify its type, T, as number.
Then, we pass in a mixed array of numbers and strings. If you inspect closely, you will observe
the red line under "some random string". Let's see what the error is in the following figure:
23 Introducing TypeScript
As you can see, we are dynamically specifying what the type of the array should be.
You could specify that the reverseArray function should accept an array of strings, as follows:
reverseArray<string>(['hello', 'world'])
Explicitly defining the type (as we've done in this case with string) provides several
advantages. It ensures that only arrays of strings can be passed to the function, which prevents
potential errors if, for example, a number is mistakenly included. Additionally, specifying the
type improves code readability, making it immediately clear to other developers (or to yourself
later on) what kind of data the function is intended to handle.
In essence, generics allow us to write a single function that can work with different types of
arrays, making our code more flexible and reusable. We don't have to write separate functions
for arrays of numbers, strings, or any other type; the same function works for all types.
At this point, you are probably wondering what happens if we fail to pass in the type. If we
don't pass in a type, TypeScript infers the type from the values (an array in this case). To get a
clear picture, see the following figure:
Figure 1.13 — Demonstrating TypeScript's type inference with the reverseArray function
In the preceding example, we have not explicitly passed in the type, yet TypeScript detects
idx_57c33374
from the array passed in what the type should be—in this case, an array of numbers. Now that
we understand how generic functions work, let's see how to use generics with classes.
Chapter 1 24
of any type:
class Stack<T> {
private items: T[] = [];
pop(): T | undefined {
return [Link]();
}
}
The preceding code demonstrates how to use the generic Stack class with different data types:
idx_e22f2729
• const stringStack = new Stack<string>();: Here, we're creating a new instance of the
Stack class that will hold string values. The <string> syntax is how we specify the
type for this particular Stack instance.
• [Link]("hello"); and [Link]("world");: Here, we're using the push
method of the Stack class to add the strings "hello" and "world" to stringStack.
• const numberStack = new Stack<number>();: Similar to stringStack, we're creating
numberStack that will hold number values.
Generic interfaces
In this example, we would create a generic Car interface that allows one of its properties to be
idx_1b8fec2a
of a dynamic type. We'll use a type variable to achieve this flexibility. Here's how you can
define the generic Car interface:
interface Car<T> {
make: string;
model: string;
year: number;
data: T; // Dynamic property that can hold any type
}
interface WarrantyInfo {
warrantyType: string;
coverageMonths: number;
expirationDate: Date;
}
const warrantyCar: Car<WarrantyInfo> = {
make: "Chevrolet",
model: "Cruze",
year: 2021,
data: {
warrantyType: "Powertrain",
coverageMonths: 36,
expirationDate: new Date("2024-02-28"),
},
};
The data property contains warranty-related information, such as the warranty type, coverage
duration, and expiration date.
By using generics, we've made our Car interface adaptable to various data types, including
insurance policies, warranties, and more. TypeScript ensures type safety, allowing us to create
versatile car objects.
They're like chameleons for our type system. This allows you to create more flexible and
adaptable type definitions in your code.
27 Introducing TypeScript
The basic idea is this: you define a type with a condition. If the condition is true, the type
resolves to one type; if the condition is false, it resolves to a different type.
The basic syntax of a conditional type is as follows:
Now, we create a Sound type that represents the sound each animal makes. If it's a cat, the
sound is 'meow'; if it's a dog, the sound is 'woof':
In this example, Sound is a conditional type. It checks whether the Animal type (T) is 'cat'. If it
is, the sound becomes 'meow'. Otherwise, it becomes 'woof'.
In the next section, we are going over the [Link] file, what it is, and how it impacts
your project.
Chapter 1 28
Configuring [Link]
In this section, we'll explore the critical aspects of [Link], the configuration file for
idx_12902bd4
TypeScript projects. We'll explain its importance and how it controls project behavior. You'll
learn how to customize compiler options to optimize your build process and type checking. As
your project grows, we'll provide tips for maintaining and expanding [Link].
So, what exactly is [Link]?
[Link] is a file that sets up your TypeScript project. It tells the TypeScript compiler
which files to use and how to turn them into JavaScript. Making a [Link] file is easy.
Just run "tsc --init" in your project's main folder. This will create a [Link] file with
default settings. You can then change these settings as needed.
configurations and explain how each one affects your work. We will break this down into
sections to help you understand better:
• Essential compiler options: These options directly influence the quality and
idx_f4076114
• Module system configuration: These options determine how modules are defined, idx_0e6430d9
advanced options to handle specific syntaxes and provide more control over the
compilation process. These options are not always required for every project, but they
idx_2312224d
are essential when working with more complex setups, such as integrating external
libraries, using JSX with React, or managing large code bases. Let's explore some of
these advanced options and understand when and why you might need them:
◦ lib: Includes additional library files containing type definitions for built-in
objects and functionalities. This expands the available types that you can use in
your project.
◦ declaration: Generates corresponding .[Link] type declaration files for external
modules or libraries used in your project. This aids in type checking and auto-
completion for developers using those modules.
• Build options: These options help manage your build process and organize your
idx_532f7c7e
project structure:
◦ [Link]: Allows you to define custom module resolution paths,
simplifying long or repetitive import statements.
Chapter 1 30
project's specific needs and preferences. By familiarizing yourself with the available options
and their impact on the build process and code behavior, you can tailor [Link] to
enhance your TypeScript development experience.
In the upcoming section, we will discuss and review some highly recommended guidelines for
effectively managing your [Link] configuration file.
structured and extensible TypeScript project. Here are some practical tips to ensure that your
[Link] file remains organized and functional:
• Focus on essentials: Prioritize core options for clarity and easy maintenance. Don't
clutter your configuration with unnecessary settings.
31 Introducing TypeScript
• Explain non-obvious settings: Use clear comments to explain settings that aren't
immediately self-explanatory. This aids future reference and understanding.
• Leverage extends: Consider extending from base configurations to inherit common
settings across multiple projects. This promotes consistency and allows project-specific
overrides when needed.
• Validate with tools: Utilize linters or dedicated validators to ensure that your
[Link] file is syntactically correct and adheres to best practices. By following
these practices, you'll ensure that your [Link] file remains clean, efficient, and
scalable, contributing to a well-structured and maintainable TypeScript project.
Summary
This chapter has equipped you with essential skills for effective TypeScript development.
You've learned about the rationale for using TypeScript, including its benefits and usage
scenarios. We covered the practical setup process, including installation and basic project
configuration. The chapter delved into foundational types, helping you master working with
strings, numbers, Booleans, and the any type. You explored working with complex types such
as arrays, tuples, and enums. Additionally, the chapter introduced advanced concepts such as
union/intersection types, aliases/interfaces, generics, and conditional types. As a bonus, we
briefly touched on [Link] configuration and best practices.
The next chapter focuses on writing clean and maintainable functions in TypeScript. You'll
delve into the Single Responsibility Principle (SRP), function signatures, parameter types,
return types, optional and default parameters, and best practices for function naming and
documentation using JSDoc and TypeScript. This chapter will further enhance your TypeScript
proficiency, empowering you to produce high-quality code with clarity and ease.
Chapter 1 32
Note: Have your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
2
Writing Clean Functions
Functions are the building blocks of any program. They allow you to break down complex
problems into smaller, simpler tasks and enable code reuse, reducing repetition. However, not
all functions are created equal—some are easy to read, understand, and maintain, while others
are messy, confusing, and error-prone. How can you write functions that are clean and
maintainable? What are the best practices and principles for designing and documenting
functions in TypeScript?
In this chapter, you'll learn how to write clean, maintainable functions by applying the Single
Responsibility Principle (SRP), avoiding side effects, and using function signatures
idx_9dc176f5
effectively. You'll also discover how to choose meaningful function names and document your
code using TSDoc and TypeDoc. By understanding function signatures, such as parameter and
return types, you can improve both the structure and readability of your functions.
Additionally, we'll explore how to use TypeScript's type inference, optional parameters, and
default values to enhance your code's clarity.
By the end of this chapter, you'll be able to write clean and maintainable functions, apply SRP,
avoid side effects, understand and use function signatures, choose meaningful names, and
document your functions using TSDoc and TypeDoc. We'll cover principles of clean functions,
function signatures, and best practices for naming and documentation.
Specifically, this chapter will cover:
• Learning principles of clean functions
• Understanding function signatures
Chapter 2 34
Technical requirements
Before proceeding, it's essential to have a foundational understanding of TypeScript basics and
its functionality. If you're new to TypeScript or need a refresher, it's recommended to
familiarize yourself with its fundamental concepts (see Chapter 1). This includes understanding
TypeScript's syntax, data types, and how it differs from JavaScript.
To get started with TypeScript, ensure you have the necessary tools and environment set up:
• A code editor or IDE (such as Visual Studio Code, IntelliJ, or Sublime Text)
• [Link] installed on your machine
• The TypeScript compiler (tsc) installed globally or locally in your project
• A basic understanding of how to create and manage TypeScript files (.ts) and compile
them into JavaScript files (.js)
You can download the example project and code for this book by following the instructions in
the Download the example code files section in the Preface of this book. This chapter's code files
are included in the downloadable code bundle.
advantages:
• Easier code readability: Clear structure and meaningful naming enhance
understanding for both yourself and future collaborators
• Reduced complexity: Breaking down logic into focused functions prevents code bloat
and simplifies maintenance
• Enhanced reusability: Well-defined functions can be easily integrated into different
parts of your application
• Improved testability: Smaller, self-contained functions are easier to isolate and test
thoroughly
35 Writing Clean Functions
Some of the principles for writing clean functions are the following:
idx_53a496b5
• Each function should have a single responsibility, meaning that it should do one thing
and do it well.
• Each function should have a clear and descriptive name that reflects its purpose and
behavior.
• Each function should have a minimal and consistent number of parameters, ideally
three or fewer, with each parameter well-defined and typed. If more are needed,
consider grouping related parameters into an object or breaking the function into
smaller parts.
• Each function should have a clear and explicit return value that is well-defined and
idx_71ad6d9e
typed.
• Each function should behave predictably and consistently, avoiding unintended effects
by keeping its operations contained within its own scope.
• Each function should be documented, using comments or annotations that explain its
functionality and usage.
Now that we have an overview of clean functions and their benefits, let's proceed to examine
each principle in detail and see how they translate into practical applications in your code.
Naming functions
Choosing appropriate names for functions is essential for writing readable and understandable
idx_8ef56d93
code. Good function names improve code clarity and maintainability and make it easier for
developers to understand the purpose of the code without diving into its implementation
details. Here are some guidelines to effectively name your functions:
• Use camel case: Start function names with a lowercase letter and use uppercase letters
for subsequent words (e.g., calculateArea, isUserLoggedIn). This convention is
widely used in JavaScript/TypeScript and ensures consistency with variable naming.
• Be descriptive: Use descriptive names that accurately convey the purpose or action of
the function. A function's name should provide a clear indication of what the function
does without needing to read its implementation. Also, avoid using abbreviations or
overly generic names (e.g., doSomething, processStuff).
• Use verbs for actions: Begin function names with a verb to indicate the action
performed by the function. This helps in distinguishing functions from variables or
other entities in the code. For example, use names such as getUserData() or
updateDatabase() to clearly indicate the actions performed by these functions.
Chapter 2 36
• Consider using prefixes: Specific prefixes can enhance clarity for certain function
types – here are some examples:
◦ is: For boolean functions (e.g., isValid(), isAuthorized())
◦ get: For functions that retrieve data (e.g., getUserName(),
getProductDetails())
• Avoid overly long names: While descriptiveness is important, overly long function
names can be cumbersome and hinder readability. Aim for a balance between clarity
and conciseness.
• Be consistent: Adhere to consistent naming conventions throughout your code base.
idx_faa2cf19
Consistency in naming helps maintain readability and makes it easier for developers to
understand and navigate the code base. If your team follows a specific convention,
adhere to it to maintain uniformity.
By following these guidelines, you can ensure that your function names are clear, descriptive,
and helpful to anyone reading or working with your code. Now that we understand the
importance of naming functions properly, we will explore a fundamental principle known as
the Single Responsibility Principle (SRP). This principle emphasizes that a function should
have a single, well-defined purpose. We'll explore how to organize your functions following
SRP principles, leading to more focused and reusable code.
functions. It states that each function should have only one reason to change, meaning that it
should have a single responsibility or a single task to perform. This makes the function more
focused, cohesive, and modular. It also reduces the complexity and coupling of the function,
making it easier to understand, test, and debug.
To apply the SRP to your functions, you should follow these steps:
idx_370c5b65
1. Identify the main purpose or goal of your function and write it down in a single
sentence.
2. Analyze your function and see if it does anything else besides its main purpose. If it
does, consider extracting those parts into separate functions.
37 Writing Clean Functions
3. Refactor your function into multiple, smaller functions if needed. Assign each new
function a clear, descriptive name that reflects its specific responsibility, as described in
the preceding section.
4. Review your function and see if it still follows the SRP. If not, repeat the previous steps
until it does.
Next, let's look at an example of applying the SRP to a function.
Suppose you have the following function that calculates the total price of a shopping cart,
idx_b74974e7
type CartItem = {
price: number;
quantity: number;
};
This function has more than one responsibility: it calculates the total price, applies the discount,
idx_7652788b
and prints the receipt. It also has a vague name that does not reflect its behavior. To make it
cleaner, we can apply the SRP and extract the different responsibilities into separate functions:
We've improved our code by applying the SRP principle. We broke down the checkout function
into three separate functions: calculateTotal, applyDiscount, and printReceipt. Each of
these functions has a clear, descriptive name and a single responsibility, making them easier to
understand and test individually.
Let's check them out:
• The calculateTotal function iterates through each item in the cart and calculates the
total price
• The applyDiscount function takes the total price and applies a discount
• The printReceipt function prints the final total and a thank-you message
Finally, we redefined the checkout function to use these smaller functions. This new checkout
idx_0048ec4f idx_a273d402
function is more concise and readable. It delegates tasks to the other functions, making the
code more modular and testable. This approach also makes the code easier to maintain and
extend, as changes to one function are less likely to impact others.
It's important to note that the SRP is not something that is TypeScript-specific but a general
principle in programming. It can help you write cleaner and more maintainable code in any
programming language and paradigm.
Now that we have mastered the SRP, let's look into another major aspect of clean coding:
managing side effects for our TypeScript functions. To make our code predictable and easier to
test, we need to have a good understanding of bad side effects and know how to avoid them. In
this next section, we will find out what side effects are, why they are bad, and how we can
avoid them for code integrity.
39 Writing Clean Functions
environment that extend beyond its local scope. For example, a function that modifies a global
variable, writes to a file, or prints to the console has side effects. These can make code
unpredictable and hard to debug, as they can affect other parts of the program in hidden ways.
Side effects are especially problematic when dealing with concurrency and parallelism, where
multiple tasks interact with shared resources.
Concurrency and parallelism refer to different ways of handling multiple tasks, and both can
idx_62c8f4ac
lead to issues when functions share resources such as global variables. Here's a quick overview
of each concept:
• Concurrency means multiple tasks are in progress around the same time but aren't
necessarily executed simultaneously. For example, asynchronous functions enable
tasks to overlap, even if they don't happen at the exact same moment.
• Parallelism refers to tasks executing simultaneously, often across multiple threads or
processors. JavaScript itself is single-threaded, but parallelism can be achieved using
Web Workers, which allow tasks to run in separate threads.
Now let's see examples of both concepts. We will look at an example for concurrency and then
one for parallelism.
Concurrency example
Imagine we have a shared global variable, currentUser, and a function that fetches user data
idx_567b6877
Now imagine we call this function twice, almost immediately after each other (concurrently).
Simulate two concurrent API calls:
fetchUserData('1');
fetchUserData('2');
Chapter 2 40
In this example, if the fetchUserData function is called twice concurrently for two different
user IDs, both fetch operations might complete at different times. This could lead to
currentUser being set unpredictably based on which API response finishes last. The global
state (currentUser) can end up in an inconsistent state, depending on the timing of these
asynchronous operations. Now let's take a look at parallelism.
Parallelism example
Imagine we need to process a large array of numbers by doubling each value. In a parallel
idx_c94b2c44
environment, different parts of the array could be processed simultaneously. While TypeScript
(and JavaScript) are single-threaded, parallelism can be achieved using Web Workers to
perform these calculations.
For simplicity, let's assume we use a worker to process chunks of the array in parallel, rather
than in the main thread. In a setup with two workers, each worker processes half of the array
independently, so both parts are being processed at exactly the same time.
By understanding and managing these potential issues, you can write more reliable,
maintainable, and testable code in TypeScript.
Now that we've explored the potential issues with side effects, let's focus on strategies for
avoiding them. By applying these techniques, you can write more reliable, maintainable, and
testable code.
avoid them in our code. By applying best practices, we can make our functions more
predictable, easier to test, and less prone to bugs. Here are some strategies:
• Pure functions: Aim to write functions that don't change or depend on external states,
such as global variables. These functions always produce the same output for the same
inputs, making them reliable and straightforward to test.
• Keep data immutable: When dealing with data, consider creating new versions of
objects or arrays instead of modifying existing ones directly. This ensures that the
original data remains unchanged, preventing unintended side effects in other parts of
the application that rely on that data.
• Local state management: For situations requiring state management, explore libraries
designed for this purpose. These tools provide controlled and predictable ways to
handle state changes within your code.
By following best practices such as avoiding side effects, using pure functions, and embracing
immutability, you can write TypeScript code that's not only cleaner and more maintainable
but also easier to test. In the next section, we'll dive deeper into the importance of function
signatures in TypeScript code, and how they help ensure type safety and code reliability.
(inputs) parameters and return type. It serves as a blueprint for how a function should be
defined and invoked. Here's what a function signature typically includes:
• Parameter types: The types of parameters that the function expects to receive. These
types define the data that can be passed to the function when it's called.
• Return type: The type of value that the function is expected to return after execution.
This specifies the data type of the value that the function will produce as output.
• Exceptions: In some cases, a function may throw exceptions. The signature can
indicate which exceptions might be thrown or passed back.
Chapter 2 42
Let's break down the components of the code above so we can understand better:
• calculateCircleArea is the function name
• (radius: number) specifies a single parameter named radius of type number
• : number indicates that the function returns a value of type number
Function signatures are essential for type checking and ensuring type safety in TypeScript.
idx_7d2cbd3e
developers. Clear, concise, and well-structured code is crucial, but equally important is
idx_ffb1f928
documentation. It's the narrative that accompanies our code, providing essential context to
those who come after us. Writing clean code isn't just about structure; it's about effective
communication.
In the past, developers relied on tools such as JSDoc to annotate their code with comments.
While helpful, JSDoc had its limitations, especially when working with TypeScript projects.
Enter TSDoc – the modern solution for documenting TypeScript code. When paired with
TypeDoc, they become a formidable duo for generating comprehensive documentation
effortlessly.
In this section, we'll demonstrate how to use TSDoc and TypeDoc to document your
TypeScript code. Let's explore how to apply these tools in a real-world scenario. Imagine
building an e-commerce platform and needing a robust ShoppingCart class with clear
43 Writing Clean Functions
documentation to ensure other developers can use it effectively. We'll define the ShoppingCart
class, including its properties and methods, and walk through the process of annotating and
documenting it using TypeDoc. This example will show you how to leverage TypeDoc's
features to generate comprehensive documentation for your TypeScript code, improving
maintainability and collaboration.
idx_6221c787
interface CartItem {
price: number;
quantity: number;
}
class ShoppingCart {
private cartItems: CartItem[] = [];›
calculateTotalPrice(): number {
let total = 0;
for (const item of [Link]) {
total += [Link] * [Link];
}
return total;
}
The preceding code snippet will serve as our starting point for exploring TypeDoc annotations
idx_27376c74
and documentation.
The code isn't bad, but we can further improve the clarity by annotating it with TypeDoc. For
instance, we could provide even more context for the applyDiscount method. What does
discount represent? Is it a percentage or a fixed amount? By annotating our code, we can
answer these questions directly in the source code, making it easier for others to understand
idx_9a9f96df
one of the methods reveals limited information. While TypeScript provides some hints, such as
indicating that it's a method and displaying its return type, there's a lack of detailed
information.
See the following screenshot for more clarity:
45 Writing Clean Functions
Figure 2.1 — Showing TypeScript's feedback when we hover over the applyDiscount method
In the next step, we start off by annotating the code with TSDoc.
TSDoc comments begin with a regular multiline comment as shown here:
/* ... */
Inside the comment block, you can add TSDoc tags to document various aspects of your code.
idx_ef0a473f
/**
* Concatenates two strings.
Chapter 2 46
• @returns: This describes the return value of a function or method. Here's an example:
/**
* @returns {Promise<User>} A promise that resolves to the user data.
*/
async function getUserData(userId: number): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
return [Link]();
}
/**
* Converts a temperature from Celsius to Fahrenheit.
* @remarks The formula used for conversion is (Celsius * 9/5) + 32.
*/
function convertCelsiusToFahrenheit(celsius: number): number {
return (celsius * 9) / 5 + 32;
}
• @deprecated: This indicates that a method or class is no longer recommended for use.
It also provides information about alternative approaches or replacements and helps
idx_d7e4dd1a
/**
* Calculates the area of a rectangle.
* @deprecated Use the `calculateRectangleArea` function instead.
*/
function getArea(width: number, height: number): number {
47 Writing Clean Functions
step by step:
1. Annotating the CartItem the interface
/**
* Interface representing an item in the shopping cart.
*/
interface CartItem {
price: number; // The price of the item.
quantity: number; // The quantity of the item.
}
In the preceding code snippet, we are annotating the CartItem interface. A docstring
comment, (/** ... */), describes the purpose of the interface. Each property, (price
and quantity), has its type (number) and a brief description.
2. Annotating the ShoppingCart class
Next, we annotate the ShoppingCart class. We make use of the @remark tag to provide
additional information about the class:
/**
* Manages a shopping cart.
* @remark The ShoppingCart class provides methods for adding items,
calculating total prices,
* and applying discounts. It is designed to be extensible and easy to use.
*/
class ShoppingCart {
private cartItems: CartItem[] = []; // Array to store cart items.
We annotate the addItem method with a description and a @param tag to describe the
idx_53d34dea idx_71d594f9
parameter it accepts. In this case, we give a hint that the item parameter must adhere
to the CartItem interface:
/**
* Adds an item to the shopping cart.
*
* @param item The item to add (must conform to the CartItem interface).
*/
addItem(item: CartItem): void {
[Link](item);
}
/**
* Calculates the total price of all items in the cart.
* @returns The total price.
*/
calculateTotalPrice(): number {
let total = 0;
for (const item of [Link]) {
total += [Link] * [Link];
}
return total;
}
a @param tag to describe its (discount) parameter. We specify that the discount is a
percentage and give insight into how the calculation happens. We also use a @returns
tag to describe its return value:
/**
* Applies a discount to the total price.
* @param discount The discount percentage (e.g., 10 for 10% off).
* @returns The discounted total price.
*/
49 Writing Clean Functions
/**
* Completes the checkout process.
* @param discount The discount percentage to apply.
* @returns The final total after applying the discount.
*/
checkout(discount: number): number {
const total = [Link](discount);
[Link]('Your total is: $' + [Link](2));
[Link]('Thank you for shopping with us!');
return total;
}
Figure 2.2 — The shopping cart shows information about what the class does when we hover
The steps we've implemented here provide significant advantages. As a code base expands, it's
idx_7aa2474e
easy to lose track of the functionality of each component. This can be particularly
Chapter 2 50
overwhelming for newcomers to the project. However, by annotating our code as we've done,
we create a self-explanatory and navigable structure that aids understanding and eases the
onboarding process.
Let's also take another look at the applyDiscount method we spoke about previously. When
idx_688d56c1
we hover over the applyDiscount method (see the following screenshot), notice what
happens:
Figure 2.3 — We can tell by merely hovering over the function what discount represents
As seen from the preceding figure, we can now better understand what the applyDiscount
method does and what the discount parameter represents, thanks to TSDoc annotations. This
clarity helps us comprehend how the discount is applied.
51 Writing Clean Functions
Now that we've mastered the art of annotating our code with TSDoc, we will further expand on
this knowledge in the upcoming section. We will continue using the same example and
integrate it with TypeDoc. This will allow us to generate static pages, essentially transforming
our code base into an easily navigable web page that serves as comprehensive documentation.
the foundation for documentation, describing what our code does. Now, let's build on that
foundation by introducing TypeDoc. TypeDoc is a popular documentation generator. It takes
the comments we added in the previous section (like the ones for the ShoppingCart class) and
transforms them into user-friendly, static web pages. In essence, TypeDoc creates an online
documentation website for your code base.
This section will walk you through a step-by-step process to show you how to use TypeDoc to
achieve this.
Let's do just that:
1. First, we'll install TypeDoc using the following command:
2. Next, we'll create a [Link] file in the root of our directory and add the following
code:
The [Link] file is where we keep all the configurations relating to TypeDoc on
our project. To the file, we have added only a minimal setup at the moment. Let's go
over what each line does:
◦ theme: "default": This sets the documentation theme to the standard layout
provided by TypeDoc, offering a clean display of information.
◦ exclude: "node_modules/**": This instructs TypeDoc to skip processing files
within the node_modules directory, typically containing external dependencies
and irrelevant information for the documentation.
3. Now, we'll add a command to trigger the documentation process. We'll add the
following script to our [Link] file:
"scripts": {
"docs": "npx typedoc ./[Link]"
},
The preceding command should generate a docs folder in your root director. See the
idx_63f88af2
Figure 2.4 — Screenshot showing an overview of the result after running the script to generate
documentation
The preceding figure displays the generated docs directory at the root of our project. This
directory houses the essential static files that comprise our web-based documentation. Now,
let's explore this documentation by accessing it and seeing how it appears in a web browser,
giving us a firsthand look at the user-friendly interface TypeDoc has created for our code base.
Drag the [Link] file into your browser and you should see something like this:
You'll see an overview of the classes/functions/interfaces you have added, which are now
idx_408bb66a
annotated. When you click on the shopping cart, you navigate to a page that contains details of
the ShoppingCart class:
You can scroll down to see more details about the ShoppingCart class or see details about a
particular method. You can click on a method – for example, if we click on the applyDiscount
method, we should see the following details:
Now that we've learned how to use TSDoc comments and TypeDoc to enhance our code's
idx_37a06c0d
clarity and generate user-friendly documentation, it's essential to establish some guidelines
for commenting our code effectively. In the next section, we'll explore the importance of
striking a balance between using comments and writing clean, readable code.
and documentation are not substitutes for good, clean code. In fact, some argue that well-
written code requires fewer comments. While there aren't strict rules, adhering to the
principles discussed in this chapter—such as the SRP, avoiding side effects, and using proper
naming—puts you on the right path.
A useful tip I learned when I started coding and first started exploring clean code principles
involves what I like to call the "Stranger Test." Essentially, you should ask yourself: If someone
who didn't know how to code sat in front of your computer and looked at your file, could they
make a rough guess of what that code does? For instance, a class named ShoppingCart
provides much more context than simply Cart. This test is a great way to ensure your code is
easily understandable, even to a stranger.
Summary
In this chapter, we delved into the fundamental principles of writing clean functions. We
explored the importance of understanding function signatures and how they contribute to
code readability and maintainability. Additionally, we emphasized the significance of using
clear and descriptive function naming, along with proper documentation, to enhance code
comprehension and collaboration within a team. By mastering these concepts, developers can
produce code that is not only easier to understand but also more robust and scalable.
Looking ahead, in the next chapter, we will transition into exploring the concepts of Object-
Oriented Programming (OOP) with TypeScript. We'll delve into defining classes,
constructors, and inheritance, as well as diving into interfaces, abstract classes, and the
principle of composition over inheritance. These topics will provide a solid foundation for
building complex and flexible software structures in TypeScript.
Chapter 2 56
Note: Have your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
3
Object-Oriented Programming
with TypeScript
Mastering object-oriented programming (OOP) is key to creating robust applications.
TypeScript, with its static typing and OOP features, provides a modern way to implement these
idx_529d24a5
principles. In this chapter, we dive into OOP with TypeScript, explaining fundamental
concepts and advanced practices for effective software design.
We'll explore classes, inheritance, interfaces, and abstract classes to understand how to
structure code for reusability, flexibility, and simplicity. We'll also discuss composition over
inheritance, a design principle for more manageable code bases. Through clear examples,
you'll learn how and why to use these techniques for confident application in your projects.
This chapter covers the following main topics:
• Understanding the concept of OOP
• Classes, inheritance, and prototype chains for robust and flexible designs
• Encapsulation to ensure data privacy and controlled access
• Polymorphism in TypeScript OOP
• Interfaces in OOP: defining contracts and ensuring consistency
• Composition over inheritance for agile development
By the end, you'll have a strong foundation in OOP with TypeScript, making you ready to
design and implement efficient, scalable code. The practices and principles discussed here will
be invaluable for refining existing projects or starting new ones in the ever-changing field of
software engineering.
Chapter 3 58
Technical requirements
The setup and technical requirements for this chapter are similar to the previous ones. Having
TypeScript and [Link] installed will be sufficient to get started.
You can download the example project and code for this book by following the instructions in
the Download the example code files section in the Preface of this book.
This chapter's code files are included in the downloadable code bundle.
TypeScript objects
Objects in TypeScript are collections of key-value pairs, providing a structured way to
idx_c47dc84a idx_9f3dd2a1
represent data and behaviors. They are fundamental building blocks in both JavaScript and
idx_6d03b85c
TypeScript, allowing you to group related information and create more complex data
structures.
59 Object-Oriented Programming with TypeScript
In TypeScript, objects can represent real-world entities or abstract concepts, making it easier to
model data in a way that aligns with your application's needs. By grouping related properties
and methods within an object, you can encapsulate functionality and maintain a clear and
organized code structure.
Object literals
An object literal is the simplest and most direct way to create an object in TypeScript. It is a
idx_2a5bbf03
method of defining an object by listing its properties and their corresponding values within
curly braces ({}). This approach is called literal because you are literally defining the object in
idx_cc1a0e94
In the cake object, flavor, size, and icing are keys, and their corresponding values are
'chocolate', 'medium', and 'vanilla', respectively. This example illustrates how object
literals can be used to represent a real-world entity—in this case, a cake—by grouping related
properties into a single, cohesive unit.
By understanding object literals, you can start creating and manipulating objects efficiently in
TypeScript, laying the groundwork for more advanced concepts, such as classes, interfaces, and
OOP.
In the next section, we'll explore TypeScript classes and learn how to use them to create
objects.
Chapter 3 60
TypeScript classes
In the previous section, we saw how to create objects using object literals. In modern
idx_634e8494
JavaScript (ES6 and beyond), and by extension TypeScript, classes provide a more structured
idx_446978cb
1. To create a class in TypeScript, you use the class keyword followed by the class name
and curly braces {}, like so:
class Cake {
// Define properties and methods here
}
class Cake {
flavor: string;
size: string;
icing: string;
}
3. Now, let's define methods for baking, decorating, and serving the cake:
idx_37f57154
class Cake {
flavor: string;
size: string;
icing: string;
bake() {
[Link]("Cake is baking in the oven!");
}
61 Object-Oriented Programming with TypeScript
decorate() {
[Link]("Adding frosting and sprinkles!");
}
serve() {
[Link]("Slicing and serving the delicious cake!");
}
}
These methods define the actions that can be performed on a Cake object. Next, let's
add a constructor to initialize the cake's properties when creating an instance.
Without initializing these properties, the TypeScript compiler will produce an error
similar to the following:
To resolve this, we'll define a constructor that takes arguments for flavor, size, and
idx_9b1f28a0
icing, and assigns these values to the corresponding properties of the class:
class Cake {
flavor: string;
size: string;
icing: string;
[Link] = size;
[Link] = icing;
}
bake() {
[Link]('Cake is baking in the oven!');
}
decorate() {
Chapter 3 62
serve() {
[Link]('Slicing and serving the delicious cake!');
}
}
4. Next, we can use this class to create new objects (instances). To do this, we use the new
keyword to instantiate Cake objects, as shown here:
Now we know how to create a class in TypeScript and how we can create instances of it
idx_46a983a7
(objects), enabling separation of concerns and code reuse. Next, let's learn about static
methods and properties in TypeScript.
at the points where we were initializing the objects of the class. These properties, such as
flavor, size, and icing, are unique to each object. But there are occasions where you want
properties and methods that are peculiar to the class itself (parent) and not the instances
(objects created from the class).
This is where static properties and methods come into play. Static members belong to the idx_c0b43739
Cake class itself. You can access them directly using the class name (e.g., Cake), without
needing to create an object first. In the following example, we will extend our Cake class to use
some static properties and methods:
class Cake {
flavor: string;
size: string;
63 Object-Oriented Programming with TypeScript
icing: string;
// Static property
static totalCakesBaked: number = 0;
// Static method
static getTotalCakesBaked () {
[Link](`Baked ${[Link]} cakes in total.`);
}
In this example, totalCakesBaked is a static property that keeps track of the total number of
Cake objects created. Each time a Cake object is instantiated, totalCakesBaked is incremented
by 1 in the constructor (totalCakesBaked++).
idx_b3c504fa
The getTotalCakesBaked static method can be called directly on the Cake class to log the
cumulative count of cakes baked. Note that within static methods, this refers to the class itself
rather than an instance of the class. This means static properties can be accessed using this
idx_bc4cd669
[Link]();
Static methods and properties are powerful tools for defining behavior and data that belong to
the class itself, not individual instances. These are particularly useful for utility functions,
counters, or any shared state or behavior. By understanding and utilizing static members
effectively, you can write more structured and maintainable code.
In this section, we've reviewed how to create and instantiate classes in TypeScript, including
defining properties and methods. We've also explored static properties and methods. With this
Chapter 3 64
foundation in TypeScript objects, classes, and properties, it's time to delve into some
fundamental concepts of OOP.
Before moving on to the next section, where we'll explore inheritance and the prototype chain,
a key concept in TypeScript OOP, let's take a moment to review what happens behind the
scenes when we define a class in JavaScript or TypeScript.
class MyClass {}
function MyClass() {}
This function is known as a constructor. To illustrate, let's create a [Link] file. In this
file, we'll define a simple Dog class with one method, bark:
class Dog {
bark() {
[Link]('Woof!');
}
}
When we run the npx tsc [Link] command, TypeScript generates the JavaScript
idx_6786bbe7
This part defines an IIFE. An IIFE is a function that runs as soon as it is defined. The
purpose here is to create a private scope for the Dog class.
• Constructor function:
function Dog() {
}
Inside the IIFE, the Dog function is defined. This function acts as the constructor for the
Dog class. When you create a new instance of Dog using new Dog(), this function is
called to initialize the object.
• Prototype method:
[Link] = function () {
[Link]('Woof!');
};
The bark method is defined on the prototype of the Dog constructor. This means that all
idx_3e2ade10
instances of Dog will share the same bark method. When you call [Link](),
it looks up the method on the prototype chain and finds this definition.
• Returning the constructor:
return Dog;
}());
The IIFE returns the Dog constructor function. The Dog function is then assigned to the
idx_7192ef87
Note
The class keyword in TypeScript is syntactic sugar. It provides a more intuitive way to
define object blueprints compared to raw JavaScript functions. This makes your code
more readable and maintainable, especially for those familiar with OOP concepts.
Chapter 3 66
Now that we understand the "secret" behind classes, we're well-equipped to explore
inheritance and the prototype chain in the next section. These concepts are fundamental to
object-oriented programming in TypeScript.
inherit properties and behaviors from existing classes (superclasses). This promotes code
reusability and reduces redundancy in your TypeScript applications.
TypeScript, being a superset of JavaScript, uses prototypal inheritance behind the scenes.
idx_df597b9d idx_342457c7
Prototypal inheritance differs from the classical inheritance model used in languages such as
Java or C++. In prototypal inheritance, objects inherit directly from other objects. However,
TypeScript provides a class-based syntax that abstracts away the prototypal nature of
inheritance, making it more familiar to developers coming from classical OOP languages. Let's
explore these two models to understand their differences better.
Classical inheritance
Classical inheritance is used in languages such as Java and C++. Here, the inheritance
idx_e4b0ce6d idx_25145937
hierarchy is more rigid. A subclass inherits from a superclass and has a fixed place in the
inheritance tree. In classical inheritance, we have the following:
• Subclass and superclass: A subclass is a specialized version of its superclass, inheriting
all its properties and behaviors
• Hierarchy: The inheritance structure is a fixed hierarchy, meaning that classes are
defined and relationships are established at the time of class creation
For example, in Java, you might define classes like this:
class Animal {
void makeSound() {
[Link]("The animal makes a sound");
}
}
In the preceding example, Dog is a subclass of Animal, and it overrides the makeSound method.
67 Object-Oriented Programming with TypeScript
Prototypal inheritance
Prototypal inheritance is a JavaScript runtime concept. Because TypeScript compiles to
idx_c141a018 idx_72827c05
JavaScript and does not introduce a new runtime or inheritance model, all inheritance in
TypeScript ultimately relies on JavaScript's prototypal inheritance.
This inheritance model is more flexible and dynamic. In prototypal inheritance, we have the
following:
• Direct inheritance: Objects inherit directly from other objects.
• Prototype chain: Objects have a prototype, which is another object from which they
inherit properties. This creates a chain of inheritance.
• Dynamic relationships: Relationships between objects can be changed dynamically at
runtime.
In TypeScript, you might define classes like this:
class Animal {
makeSound() {
[Link]('The animal makes a sound');
}
}
Under the hood, TypeScript transforms these class definitions into JavaScript functions and
idx_21b87ff4
function Animal() {}
[Link] = function () {
[Link]('The animal makes a sound');
};
function Dog() {}
[Link] = [Link]([Link]);
[Link] = Dog;
[Link] = function () {
Chapter 3 68
In this example, the Dog constructor function's prototype is set to an object created from
idx_5ad66dbe
Key differences
When comparing classical inheritance with prototypal inheritance, there are several important
idx_764f50d6 idx_42aeb121 idx_56897902
distinctions to keep in mind. In the following, we outline the key differences between these
two inheritance models:
• Hierarchy:
◦ Classical inheritance: Fixed and rigid hierarchy. Classes are defined once, and
their relationships do not change.
◦ Prototypal inheritance: Flexible and dynamic. Objects can inherit from other
objects, and these relationships can change at runtime.
• Inheritance model:
◦ Classical inheritance: Classes and subclasses.
◦ Prototypal inheritance: Objects and prototypes.
• Method overriding:
◦ Classical inheritance: Methods in the subclass override methods in the
superclass.
◦ Prototypal inheritance: Methods on the prototype of a child object override
methods on the prototype of a parent object.
Understanding the differences between classical and prototypal inheritance helps in effectively
idx_b917b766 idx_96d0c5bb
JavaScript. TypeScript runs on top of JavaScript and uses JavaScript's object model at runtime,
so the way prototypes behave is the same as it is in JavaScript.
69 Object-Oriented Programming with TypeScript
When you access a property on an object, JavaScript first looks for that property on the object
itself. If it doesn't find it there, it continues searching up the prototype chain until the property
is found or the end of the chain is reached.
TypeScript works with this same prototype system. It adds type information on top of
JavaScript's behavior, helping you understand and validate how objects relate to each other at
development time, while the actual property lookup and inheritance behavior are handled by
JavaScript at runtime.
Let's see an example in the following screenshot for further details.
idx_2f9be97d
In Figure 3.2, we've created a simple cake object in our browser console. If you log the cake
idx_4e9b7f6a
variable in the console, you will see the object details. When you look closely, you can see the
[[Prototype]] property of the object. Every object you create has this property. When you
click on the arrow, you can see more details, as shown in Figure 3.3:
Chapter 3 70
If you noticed, even the [[Prototype]] property has its own __proto__. If you click on the
arrow of that __proto__ property to see more details, you find that it also has a __proto__,
which is null this time. For clarity, see Figure 3.4:
idx_0d6d5e81
71 Object-Oriented Programming with TypeScript
Now that we've learned about the prototype chain, let's see how we can set it up ourselves.
object. This can be useful when you want to create an object with a specific prototype. For
example, let obj = [Link](protoObj); creates a new object, obj, with its prototype
set to protoObj.
Let's see the following example for more details:
const cakePrototype = {
bake() {
[Link]('The cake is baking.');
},
};
In this example, chocolateCake is an object that inherits from cakePrototype. This means
chocolateCake can use the bake method defined in cakePrototype, demonstrating basic
prototype inheritance.
flavor: string;
size: string;
icing: string;
serve() {
[Link](`Serving the delicious ${[Link]} cake!`);
}
}
// Example usage
const chocolateCake = new Cake('chocolate', 'large', 'chocolate ganache');
[Link](); // Inherited method from Oven
[Link]();
[Link]();
1. We define a new class, Oven, with a bake method that simulates baking functionality.
2. The Cake class now inherits from Oven by using extends.
3. Inside the Cake class constructor, we call super() to ensure proper initialization is
inherited from Oven.
4. We define properties for flavor, size, and icing specific to Cake objects.
Chapter 3 74
5. We add the decorate() and serve() methods to the Cake class, demonstrating typical
actions that might be performed on a cake after baking.
6. When creating a Cake object (e.g., chocolateCake), it inherits the bake method from
Oven and can use its own methods, decorate() and serve().
The preceding setup uses class inheritance to model a real-world relationship in which a cake
is a specific type of baked good that uses an oven for baking, showing how an object-oriented
approach can effectively model real-world hierarchies and behaviors in software design.
In this section, we've looked at inheritance and prototype chains. We've learned about
prototypes in TypeScript and how they form the backbone of TypeScript's object model. We've
seen prototype-based inheritance, a way of creating objects that inherit properties and
methods from other objects. We've seen how to use [Link] to set up the prototype
chain, a mechanism for having objects inherit features from one another. And we've gone
through ES6 class inheritance with the extends and super keywords, which allow a more
idx_853c2ade
the methods (operations) that act on that data within a single unit, typically a class. This
concept ensures that the internal representation of an object is hidden from the outside, only
allowing access through a well-defined interface.
Think of encapsulation as protecting your cake's secret recipe. Just as you wouldn't want
anyone to alter your cake batter without permission, in programming, you want to make sure
that certain data is only accessible to specific parts of your code. This promotes data privacy
and controlled access, ensuring the integrity and proper functioning of your objects.
In this section, we'll explore the importance of data privacy and how encapsulation helps
achieve it. We'll delve into practical implementations, including getters and setters, which act
as gatekeepers to your class's data. By understanding and applying encapsulation, you'll be
able to create more secure, maintainable code.
75 Object-Oriented Programming with TypeScript
way. You can potentially perform additional logic before returning the value, such as
formatting or validation.
• Setters: These methods allow you to modify the value of a property in a controlled way.
idx_14265dcd idx_f4b4fb91
You can use them to enforce specific rules or data validation before assigning a new
value.
Here's how you can use getters and setters in the Cake class:
class Cake {
constructor(flavor, size, icing) {
this._flavor = flavor;
this._size = size;
this._icing = icing;
}
get flavor() {
// Getter for flavor property
return this._flavor.toUpperCase();
}
set flavor(newFlavor) {
// Setter for flavor property
if ([Link] < 3) {
throw new Error('Flavor must be at least 3 characters long!');
}
this._flavor = newFlavor;
}
how your data is accessed and modified. They provide a layer of abstraction over your data,
idx_208bab9d
allowing you to enforce specific rules and perform additional logic. In our Cake class example,
we used a getter to format the flavor property and a setter to validate the length of a new
flavor before assigning it. This ensures that our Cake objects always have valid and properly
formatted data.
In the next sub-section, we look at access modifiers, something else that's part of
encapsulation.
class members (properties and methods). They help enforce encapsulation by restricting
access to certain parts of your code.
• public: The default modifier. Members with public can be accessed from anywhere,
both inside and outside the class.
• private: Members with private can only be accessed within the class itself. This
prevents external code from directly modifying these members.
• protected: Members with protected can be accessed within the class and its
subclasses. This allows inheritance while still restricting external access.
Here is an example of how access modifiers would be used. We will stick with the cake
idx_440f43a1
example:
class Cake {
private _flavor: string;
public size: string;
protected icing: string;
get flavor() {
return this._flavor.toUpperCase();
77 Object-Oriented Programming with TypeScript
In the previous code, we made the _flavor property private, which means it cannot be
idx_9f8c7d2f
accessed directly from outside the class. The size property is public, so it can be accessed from
anywhere. The icing property is protected, meaning it can be accessed within the Cake class
and any subclasses that extend Cake.
Now let's see what happens when we try to access the properties:
In conclusion, access modifiers are essential tools in TypeScript for controlling the visibility
and accessibility of class members, enhancing encapsulation, and maintaining code integrity.
In the next section, we will delve into the concept of polymorphism. This is another
fundamental concept in OOP that allows objects to take on many forms, further enhancing the
flexibility and reusability of our code.
method defined in a base class, Shape, can be customized by subclasses such as Circle,
Rectangle, and Triangle. Calling draw() on each object will execute the appropriate version
for its type, enabling different behaviors from a single method call.
There are two main ways to achieve polymorphism in TypeScript: method overriding and
interfaces. Let's look at these two approaches in more detail.
Chapter 3 78
Method overriding
Method overriding is a key aspect of polymorphism in TypeScript. It allows you to define a
idx_4e1bca20 idx_f657fd75
method in a base class and then override it in child classes to provide a more specific
implementation. This enables you to write more generic code that can work with different
classes.
To implement method overriding in TypeScript, follow these steps:
1. Define a base class with a method that has a general implementation.
2. Create child classes that inherit from the base class.
3. Override the method in the child class to provide a more specific implementation.
Let's see the example:
class PaymentMethod {
processPayment(amount: number) {
// This method will be overridden in each subclass
[Link](`Processing default payment: ${amount}`)
}
}
In this example, when we call processPayment on each item in the paymentMethods array, the
idx_a13bebee
overridden method in each subclass (CreditCard and DebitCard) is executed instead of the
idx_cc1d8267
base class method. This demonstrates polymorphism in action, allowing each subclass to
respond differently to the same method call.
79 Object-Oriented Programming with TypeScript
Interfaces
Interfaces in TypeScript are a very potent way to define contracts within your code. They offer
idx_d0a4ce3c idx_cbb179de
a way to define a type by the shape of the data. Interfaces thus give us a chance to interact with
various classes in the same way as long as they implement the same interface.
Let's see how we can implement the example in the preceding section using interfaces:
interface PaymentMethod {
processPayment(amount: number): void;
}
In this example, CreditCard and DebitCard are separate classes that implement the
PaymentMethod interface. They are not related by inheritance, but they can be used
interchangeably because they implement the same interface.
The important question now is not what they are, but when to use one over the other.
idx_b65fc9d9
An interface is best used when you want to define a contract, a common shape, or an API that
different implementations can follow. Interfaces work especially well when multiple,
unrelated classes need to expose the same behavior. Because interfaces exist only at compile
time, they provide flexibility without introducing runtime coupling.
Chapter 3 80
between classes. Inheritance establishes a "parent-child" hierarchy, where the child class
inherits properties and behaviors from the parent. Composition, on the other hand, describes
how an object is built from other objects.
Inheritance is useful when there is a clear is-a relationship between two classes. For example, a
dog is a kind of animal. They share common characteristics and behaviors (such as eating and
moving) that can be defined in the Animal class and inherited by Dog. This promotes code reuse
and reduces redundancy.
Let's check the following code for more details:
class Animal {
breathe() {
[Link]('Breathing');
81 Object-Oriented Programming with TypeScript
}
}
In the preceding example, Dog inherits from Animal, gaining its behavior while adding specific
idx_a8dafb98
class Band {
singer: Singer;
guitarist: Guitarist;
drummer: Drummer;
constructor() {
[Link] = new Singer();
[Link] = new Guitarist();
[Link] = new Drummer();
}
perform() {
[Link]();
[Link]();
[Link]();
}
}
Chapter 3 82
In the example we just considered, Band doesn't inherit functionalities from Singer,
Guitarist, or Drummer. Instead, Band has Singer, a Guitarist, and a Drummer. It composes
these objects and delegates specific functionalities (singing, playing guitar, playing drums) to
them. This allows for greater flexibility.
You can easily change the composition of the band. Maybe it has a backup singer or a keyboard
idx_40ee808d
player. Composition allows for this flexibility without modifying the core Band functionality.
Each object can be independently extended or modified without affecting the others.
By understanding and applying these concepts, you can write more flexible and maintainable
TypeScript code. Remember, while inheritance can be useful in some cases, composition can
often be a more powerful and flexible approach.
Summary
This chapter provided a thorough understanding of OOP concepts as applied in modern
JavaScript and TypeScript. We began with an introduction to OOP, its significance, and
comparison with other paradigms. We then explored objects and methods, followed by a deep
dive into classes, including their creation, instantiation, and key features such as constructors,
the this keyword, and static properties. Throughout the chapter, we examined how
TypeScript builds on JavaScript's OOP model by adding type safety and improved developer
tooling.
The chapter also covered inheritance and prototype chains, encapsulation for data privacy, and
polymorphism. We concluded with a discussion on composition versus inheritance, providing
guidance on when to use each and the benefits of composition. The knowledge gained will
enable cleaner and more maintainable TypeScript code.
In the forthcoming chapter, we will be harnessing the knowledge we've accumulated thus far.
Our focus will be on its practical application within the framework of a TypeScript project. We
will embark on constructing a sample project, providing us with a hands-on opportunity to
implement our understanding. This will involve organizing and modularizing code and
ensuring adherence to the principles we've previously discussed. This chapter promises to be
an enriching journey from theoretical understanding to practical implementation.
83 Object-Oriented Programming with TypeScript
Note: Have your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
4
Clean Code in TypeScript
Projects
In the previous chapters, we laid the foundation for writing clean code within the TypeScript
context. We covered the installation and setup of a TypeScript project, discussed the principles
of clean functions and clean code, delved into object-oriented programming (OOP), and
explored documenting our code with TypeDoc and TSDoc.
In this chapter, we will advance these concepts and integrate them into a complete TypeScript
project. Our focus will be on the importance of code organization, modularization, and
adherence to clean code principles to create robust and scalable projects. We will examine
feature-based versus function-based folder structures and provide guidance on selecting the
most suitable approach for your project. Additionally, we will explore the module system,
different types, and how they work. We will also dive into dependency management,
comparing various systems, such as npm, Yarn, and pnpm. Finally, we will cover linting and
setting up custom checks to ensure code consistency and quality.
Note
TypeScript configuration was discussed in detail in Chapter 1, and we assume
familiarity with it for this project-based chapter.
By the end of this chapter, you will have gained a comprehensive understanding of how to
structure and organize your TypeScript projects effectively. You will learn how to manage
dependencies, configure TypeScript settings, and implement tools for maintaining high code
quality.
Technical requirements
You can download the example project and code for this book by following the instructions in
the Download the example code files section in the Preface of this book.
This chapter's code files are included in the downloadable code bundle.
project. It's the foundation upon which your code is built, and a good structure can make all
the difference in the world. Imagine trying to find a specific file in a project with no clear
organization—it's like searching for a needle in a haystack!
In any TypeScript project, a well-defined folder structure is essential for several reasons. It
enhances code readability, making it easier for developers to understand the project at a
glance, which is crucial in collaborative environments where multiple team members
contribute to the code base. Additionally, a structured project improves maintainability, as it
helps manage complexity and ensures that new features can be added without creating chaos.
Lastly, a well-organized structure supports scalability, allowing the project to evolve and
idx_b294728d
expand without requiring significant restructuring. By implementing a clear and logical folder
structure, you'll be able to navigate your project with ease, find files quickly, and build a solid
foundation for your code.
In this section, we will explore best practices for organizing your project files and directories to
ensure your TypeScript project remains maintainable and scalable as it grows. First, let's look
at what a standard folder structure for TypeScript projects would be.
in a clear and maintainable way. While the exact structure can vary depending on the
framework and tooling, the following elements are frequently seen across modern TypeScript
projects.
• src (source) directory: The src directory contains all the source code files. This is
where you organize your TypeScript files based on their roles and functionalities.
87 Clean Code in TypeScript Projects
• dist (distribution) directory: The dist directory holds the compiled output. After the
TypeScript code is transpiled to JavaScript, the files are placed here, ready for
deployment.
• config directory: The config directory stores configuration files. This includes settings
for tools such as webpack, Babel, or environment variables.
• Test directory: In modern TypeScript and React projects, tests are often colocated with
the code they test (for example, [Link] next to [Link]). This approach
aligns well with component-driven development and is supported by tools such as
Vite, [Link], Jest, and monorepo setups such as Nx or Turborepo. Some projects may
still use a separate test directory, depending on team preference and tooling.
• [Link] file: A root-level [Link] file is often used for exporting modules, providing a
single-entry point to your project's components.
In the preceding list, we discussed the standard folder structure in a typical TypeScript project.
idx_325ef516
While this structure is common, it doesn't dictate how elements within these folders, such as
UI components or services, should be organized. To address this, developers often consider two
primary approaches for grouping their code into folders: feature-based organization and
idx_09fdd398
function-based organization. In the following sections, we will dive deeper into the specifics
idx_0f1a7182
approach, code is grouped into independent modules or packages, each representing a distinct
business capability, such as authentication, billing, or notifications.
This style is commonly used in monorepo setups with tools such as Yarn workspaces, Nx, or
Turborepo, where each module can have its own internal structure, dependencies, and
ownership boundaries. Module-based organization becomes especially valuable as teams
scale, allowing different parts of the system to evolve independently while maintaining clear
contracts between them.
In practice, project structure often evolves over time. Teams may start with a simple function-
based or feature-based layout and gradually transition to a module-based approach as
business requirements expand and code ownership becomes more distributed.
Chapter 4 88
Feature-based organization
In feature-based organization, files and directories are structured around specific features of
idx_bcb90270
the application. For example, you might have a directory called cart that contains all the files
idx_8b0a3072
related to the shopping cart feature, including the cart component, cart service, and cart
reducer.
Here are the pros and cons of feature-based organization:
First, let's look at the pros:
idx_65267c2e
Function-based organization
In function-based organization, files and directories are organized around specific functions or
idx_6669631d
layers of the application, such as components, services, and utilities. This approach groups
idx_ada07624
code by what it does, rather than the feature it serves, similar to a toolbox where tools are
grouped by type, regardless of the project they're used for.
89 Clean Code in TypeScript Projects
For example, you might have a directory called components that contains all the component
files, a directory called services for all service files, and a directory called utils for utility files.
Here are the pros and cons of function-based organization.
idx_e2978290
• Coupling: May result in a tight coupling between layers, making it difficult to maintain
and evolve the application
• Flat directory hierarchy: Can lead to a flat directory hierarchy, making it difficult to
locate files related to a specific feature idx_2bdaeec7
organized by feature at the top level, and within each feature, files are grouped by function or
responsibility (such as components, services, or utilities). This balances feature cohesion with
clear internal structure and scales well as applications and teams grow.
or function:
First, let's look at a feature-based approach:
src/
└── products/
├── [Link]
├── [Link]
└── [Link]
└── cart/
├── [Link]
└── [Link]
└── users/
├── [Link]
└── [Link]
Chapter 4 90
src/
└── components/
├── [Link]
├── [Link]
├── [Link]
└── [Link]
└── services/
├── [Link]
├── [Link]
└── [Link]
• The services/ directory includes files for backend services (e.g., [Link],
[Link], [Link]). These files handle business logic, data fetching,
and other backend operations for different parts of the application.
This organization allows for centralized and reusable code within each functional area,
promotes separation of concerns, and makes it easier to maintain and extend the application.
Now, let's look at a hybrid approach:
src/
products/
components/
[Link]
[Link]
services/
[Link]
cart/
components/
91 Clean Code in TypeScript Projects
[Link]
services/
[Link]
users/
components/
[Link]
services/
[Link]
This hybrid structure keeps feature-related code together, while maintaining clear separation
idx_d8fe3889 idx_a3db8377
related functions, variables, classes, and interfaces into self-contained units. Think of modules
like drawers in a toolbox – each drawer holds specific tools you need for a particular task. Just
like tools in a drawer have a defined purpose, modules encapsulate functionalities within your
application.
separate files and directories, each containing related code. This helps in managing and
maintaining large code bases by keeping related functionalities together and separating
unrelated ones.
But why are module systems so valuable? Let's explore how they can transform your code.
Module systems improve code maintainability and reuse in the following ways:
• Encapsulation: Keeping related code together and preventing it from interfering with
idx_b94c2254
related functionalities
By the end of this section, you will have an understanding of how to implement module
systems in TypeScript and how to use them for improved code maintainability and reuse. We
Chapter 4 92
will cover the basics of creating and using modules, including how to export and import
functionalities between different modules.
To better understand how module systems work in TypeScript, the following subsections will
walk through a practical example. We'll demonstrate how to define a simple module and
integrate it into other parts of your application. By following along, you'll see how modules not
only organize your code but also enhance maintainability and promote reusability.
Defining a module
To start, we define a simple module containing a User class. We do this by creating a file called
idx_fcfbdc71
(src/models/[Link]), which encapsulates its functionality and keeps the code organized.
Here's how we create it:
// src/models/[Link]
export class User {
constructor(public id: number, public name: string) {}
}
In this example, the User class has a constructor that initializes the id and name properties. The
export keyword makes the User class accessible to other parts of the application by allowing it
idx_03fdbef9
contained. This prevents variables and functions from polluting the global scope, avoids
idx_0950cf6c
// src/services/[Link]
import { User } from '../models/User';
}
}
Here, the User class is imported into the UserService module. The encapsulation ensures that
the User class is only accessible where it is explicitly imported, maintaining a clear structure
and scope within the application.
Additionally, let's see how encapsulation applies to utility functions:
idx_9cacd922 idx_bd8a94de
// src/utils/[Link]
export function add(a: number, b: number): number {
return a + b;
}
By encapsulating these functions within the MathUtils module, we prevent potential conflicts
and ensure that they can be imported selectively when needed.
// src/[Link]
import { add, subtract } from './utils/MathUtils';
Now that we understand abstraction, let's move on to the next step, which involves reusability.
import existing code into different parts of your application. This reduces duplication and
promotes the Don't Repeat Yourself (DRY) principle.
idx_18696e49
// src/utils/[Link]
export function log(message: string): void {
[Link](`[LOG]: ${message}`);
}
Chapter 4 94
This logging utility can then be imported into various modules across your application,
idx_124ef632
// src/components/[Link]
import { log } from '../utils/Logger';
export function ComponentA() {
log('ComponentA initialized');
}
The next thing we'll explore is how modules make your code significantly easier to maintain.
Maintainability
By organizing code into modules, you can manage and maintain your code base more
idx_be037f80
effectively. Changes in one module do not affect other modules, making it easier to track bugs
and implement new features.
Scalability
As your application grows, modules make it easy to scale without disrupting existing
idx_91d90459
functionality. For example, you can add a new module for handling orders in an e-commerce
application without affecting the User or UserService modules. Let's see the following
example:
// src/models/[Link]
export class Order {
constructor(public orderId: number, public userId: number, public amount: number)
{}
}
This new Order module can be added seamlessly, demonstrating the scalability of modular
idx_8d3153eb
code. The last thing we will look at is how organizing your code by modules can improve
testing.
Improved testing
Modules make it easier to write unit tests by allowing you to test individual pieces of
idx_277031b2
functionality in isolation. For instance, you can write unit tests for the User class and
UserService module separately, ensuring that each piece of functionality works as expected.
// src/tests/[Link]
By testing the User class in isolation, you can ensure its functionality without being affected by
other parts of the application.
Now that we understand what the module system is and how it improves our code, let's move
on to the next section, where we will explore the various types of module systems used in
TypeScript/JavaScript. We will focus primarily on the two most commonly used ones:
idx_62060157
ECMAScript Modules (ES6 Modules) and CommonJS. Additionally, we'll make quick
references to other types of module systems, such as Asynchronous Module Definition
(AMD), Universal Module Definition (UMD), and SystemJS.
ES6 modules
Introduced in ES2015 (ECMAScript 6), ES6 modules provide a standardized way to organize
idx_481a46ba
Basically, in the previous code, we use the import statement to bring the formateDate function
from the utils module.
Now that we have an idea of ES6 modules, let's see some advantages of using ES6 modules:
idx_f6029736 idx_e818512c
• Static imports: ES6 modules are statically analyzed, allowing tools and compilers to
optimize the code before execution
• Browser support: Natively supported in modern browsers
• Tree Shaking: Facilitates tree shaking, where unused code can be eliminated during
the build process
This should give you an idea of why ES6 modules are preferred in certain cases. Now that we
have an understanding of ES6 modules, the next module type we'll look at is CommonJS.
CommonJS
CommonJS is the default module system in [Link] and is widely used in backend
idx_23494843 idx_4d524465
development. Modules are loaded dynamically at runtime using the require function.
To export a module in CommonJS, unlike ES6 modules that use the export keyword, you need
to use [Link]. Here's an example similar to the one in the ES6 module section:
In this code, we define a formatDate function and export it using [Link], making it
available to other files. The formatDate function accepts a date parameter, applies some
formatting logic, and returns the date as a string.
To use this exported function in another file, we import it using the require function:
idx_47e627e7
In this example, we use require('./utils') to load the entire utils module. The utils
idx_35cb0fa3
object contains the exported formatDate function, which we call to format the current date.
Finally, we log the formatted date to the console.
The dynamic nature of CommonJS modules, mentioned previously, offers flexibility, but makes
code analysis and optimization more challenging.
Other module systems, such as AMD, SystemJS, and UMD, have been used in the past, but they
are not as prevalent in modern development. As a result, we won't be diving into them in this
book. In the next section, we'll focus on managing dependencies in TypeScript projects.
wheel from scratch. We leverage the power of existing code libraries and tools; these are your
dependencies. Dependencies provide essential functionalities and features that you integrate
into your project, saving you time and effort.
But why do dependencies matter? Let's find out:
• Faster development: By utilizing pre-written, well-tested code, you don't need to
build everything yourself. This accelerates development and allows you to focus on
your core application logic.
• Code quality and consistency: Established libraries often adhere to high coding
standards and best practices, improving the overall quality and maintainability of your
project.
• Community support: Dependencies often come with a vibrant community, providing
readily available documentation, tutorials, and support when you encounter issues.
As you integrate dependencies into your TypeScript project, it's crucial to understand that not
idx_46621f0e
all dependencies serve the same purpose. Some are used only during development, while
others are necessary for your application's core functionality in production. To effectively
manage and optimize your project, you need to know the difference. Let's explore the two main
types of dependencies you'll encounter in TypeScript projects: development dependencies and
production dependencies.
Chapter 4 98
Types of dependencies
There are two types of dependencies in a TypeScript project: development dependencies and
production dependencies:
• Development dependencies are required for building, testing, and running your
project during development but are not included in the final production build.
idx_31ab8d0e idx_deaa0251
Examples include testing frameworks (Jest), bundlers (webpack), and linters (ESLint).
• Production dependencies are essential for the core functionality of your application
idx_64d62f6f idx_e35f483f
and are included when deploying your project to a production environment. Examples
include web frameworks (React, Angular), data access libraries (Axios), and UI
component libraries (Material UI).
Here are the common dependencies in TypeScript projects:
idx_3808fe06
and managing the libraries and packages that a project depends on. They ensure that the right
versions of dependencies are installed and help in resolving conflicts between different
packages.
In the [Link] ecosystem, the most common dependency managers are npm, yarn, and pnpm.
Each of these has its own set of commands for managing dependencies, but they all share some
common ones.
99 Clean Code in TypeScript Projects
to npm.
Installation: npm install -g yarn (global installation)
Basic commands:
◦ yarn add <package-name>: Installs a package
◦ yarn remove <package-name>: Uninstalls a package
◦ yarn list: Lists installed dependencies
• pnpm: A relatively new dependency manager that focuses on performance and
idx_53e2cbaa idx_59d77d61
efficiency.
Installation: npm install -g pnpm (global installation)
Basic commands:
◦ pnpm add <package-name>: Installs a package
◦ pnpm remove <package-name>: Uninstalls a package
◦ pnpm list: Lists installed dependencies
Chapter 4 100
Default with
Yes No No
[Link]
Comes bundled
Installation Installed via npm Installed via npm
with [Link]
Content-
Dependency
Flat node_modules Flat node_modules addressable store
storage
with symlinks
Dependency Partial
Better than npm Strict and efficient
deduplication deduplication
Large projects,
Beginners, simple Teams wanting monorepos,
Best suited for
projects stability and speed performance-
focused teams
performance improvements, and security patches. It also helps to maintain compatibility with
other packages and tools. Each dependency manager has its own built-in tool for checking
outdated packages. Let's see the commands for the different dependency managers:
• npm: npm outdated
• Yarn: yarn outdated
• pnpm: pnpm outdated
These commands will scan your project's [Link] file and identify dependencies with
newer versions available in the registry. I have run the npm outdated command on an older
project and got the result in the following screenshot:
idx_edf5b185
Figure 4.1 displays the output of running npm outdated. Let's break down the columns:
• Package: Lists the project's dependencies, including react and react-dom
• Current: Shows the currently installed version of each package
• Wanted: Displays the desired version, which is the newest stable version compatible
with the project
• Latest: Indicates the newest available version, which may include breaking changes
• Depended by: Reveals the libraries or modules within the application that rely on
these dependencies
Now that we have learned how to check for outdated packages, let's look at how you update
outdated packages in your project.
Chapter 4 102
Updating dependencies
When working on a project, it's important to manage and update the various libraries and
idx_fe1cbd6a
packages (dependencies) your project relies on to benefit from bug fixes, security patches, and
new features. However, updates can sometimes introduce changes that affect your code. To
manage this, most dependencies follow a versioning system called Semantic Versioning
idx_34714853
(SemVer).
This system uses a format of [Link] to indicate the significance of changes in a
new version. Let's learn a bit more about them:
• Major: Introduces breaking changes that might require code modifications in your
project
• Minor: Adds new features or functionalities while maintaining backward compatibility
• Patch: Fixes bugs or security vulnerabilities without introducing breaking changes
It's especially important to be cautious when upgrading to a major version. Major releases
often introduce breaking changes, such as altered APIs, deprecated methods, or behavioral
differences that can impact existing code. Before upgrading, it's a good practice to review
release notes, test changes in a controlled environment, and update dependent code as needed
to avoid unexpected issues.
Understanding SemVer is crucial when updating dependencies. It helps you anticipate
potential breaking changes caused by major version bumps and plan your update strategy
accordingly.
Now let's look at the relevant commands used to update dependencies for the different
idx_fd0a0ab2
dependency managers:
• npm:
◦ npm update <package-name>: Updates a specific package to the latest
compatible version (based on the SemVer range in [Link])
◦ npm update: Updates all installed dependencies to their latest compatible
versions
◦ npm update <package-name>@<version>: Updates a package to a specific
version
• Yarn:
◦ yarn upgrade <package-name>: Updates a specific package to the latest
compatible version idx_bd4dafc2
103 Clean Code in TypeScript Projects
changes)
• pnpm:
◦ pnpm update <package-name>: Updates a specific package to the latest
compatible version
◦ pnpm update: Updates all installed dependencies to the latest compatible
versions
◦ pnpm update <package-name>@<version>: Updates a package to a specific
version
◦ pnpm update --interactive: Updates all packages, prompting you to confirm
each upgrade before proceeding
Now that we understand SemVer and how packages are versioned and have seen how to
update packages in our project, let's explore some strategies for safely updating dependencies.
Here are some strategies for safely updating dependencies:
idx_d98db400
After ensuring that your dependencies are safely updated, the next step in maintaining a
healthy code base is focusing on code quality and consistency. As your project grows and
multiple developers contribute, it's crucial to enforce a uniform coding style and catch
potential issues early. In the following section, we'll explore how tools such as ESLint,
typescript-eslint, and Prettier can help achieve this by automating linting and formatting in
your TypeScript projects.
Setting up ESLint
In this section, we'll walk through how to set up ESLint in a TypeScript project. ESLint helps
idx_86a01ac6
catch errors and enforce consistent code quality. We'll go through three main steps:
following command:
These packages enable ESLint to understand TypeScript syntax and apply recommended
linting rules.
105 Clean Code in TypeScript Projects
prompts:
When you run this command, you'll be prompted to answer a series of questions to configure
ESLint based on your project setup. A screenshot of this process is shown in Figure 4.2.
Figure 4.2 — Showing the prompts for initializing ESLint on our project
The previous command would create an [Link] file in the root directory of your
project. The file holds your ESLint configuration for your project. I have added a screenshot of
what the contents look like in Figure 4.3 and added comments to the code to give more insight
into what the different lines of code do:
Now that we have ESLint set up on our project, it's time to put it to the test.
common mistake.
Create a file named [Link] with the following code:
This command tells ESLint to lint all .ts files in the current directory. If configured properly,
you'll see an error message in the console, as shown in the following figure:
Congratulations! ESLint has successfully caught the error. Your project is now equipped with
basic linting.
For more details on rules and checks you can add to your project, you can refer to the
idx_616438b2
documentation: [Link]
In the next section, we will look at setting up Prettier, a tool that helps us ensure consistency in
our code formatting.
consistent. It automatically formats your code according to a set of rules and preferences,
idx_d1888087
saving you time and effort. Prettier supports a wide range of programming languages,
including TypeScript, JavaScript, HTML, CSS, and many others.
In this section, we are going to look at how to set up Prettier on your project:
1. Install Prettier: Install Prettier along with the necessary ESLint plugin for idx_9ed99c14
compatibility:
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80
}
Chapter 4 108
integration:
If you run this command, your code should be formatted according to the rules we set.
For instance, we have specified that the code should use single quotes only; once we
run the Prettier command, you will notice that all double quotes become single quotes.
The same applies to spacing and other formatting rules.
Summary
In this chapter, we explored key practices for writing clean, maintainable, and scalable code in
TypeScript projects. We began by discussing best practices for structuring folders,
emphasizing the importance of organizing projects in a way that promotes ease of navigation
and long-term maintenance. We also delved into module systems, highlighting their role in
improving code reuse and maintainability. Furthermore, we examined how to manage
dependencies effectively, covering package managers, strategies for updating dependencies in
large-scale projects, and the use of semantic versioning (semver) to ensure compatibility. To
maintain code quality and consistency, we introduced linting and code formatting tools such
as ESLint, TSLint, and Prettier. By applying these techniques, you can ensure your TypeScript
code base remains efficient and easy to work with as your project grows.
In the next chapter, we will shift our focus to ensuring the quality and reliability of your code
by establishing a solid testing strategy, covering essential topics such as unit testing,
integration testing, and test-driven development (TDD).
109 Clean Code in TypeScript Projects
Note: Have your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
5
Testing and Test-Driven
Development
In this chapter, we will focus on building a comprehensive testing strategy for TypeScript
projects. Testing plays a vital role in software development, ensuring that your code remains
reliable, maintainable, and performs well. By the conclusion of this chapter, you will gain a
solid understanding of various testing types and how to implement them effectively in
TypeScript. Furthermore, we will explore Test-Driven Development (TDD), a methodology
idx_f859dd65
that emphasizes writing tests before coding, helping to enhance both the design and quality of
your applications.
In this chapter, we're going to cover the following main topics:
• Grasping the fundamentals of testing
• Understanding levels of testing in software
• Unit testing in TypeScript
• Integration testing in TypeScript
• Introducing TDD and its benefits
Technical requirements
To follow along with the examples in this chapter on testing and TDD, you will need [Link]
version 20.0.0 or later and TypeScript version 5.0 or later installed on your machine. Recent
versions of modern testing tools, such as Vitest, require [Link] 20 or newer, and using an
older version may result in installation warnings or runtime errors.
You can download the example project and code for this book by following the instructions in
the Download the example code files section in the Preface of this book.
Chapter 5 112
This chapter's code files are included in the downloadable code bundle.
will learn about the importance of testing, the different levels of testing, and their respective
objectives. This knowledge will provide a solid foundation for the rest of the chapter, where we
will delve into more specific testing strategies in TypeScript.
Software development often involves building complex systems with intricate functionalities.
Without proper testing, it's easy for unforeseen problems to slip through the cracks. Testing
helps you catch these issues before they reach production, saving time and resources in the
long run. Additionally, well-written tests contribute to code maintainability, as they serve as
documentation for the intended behavior of your application.
Let's look at a simple example of a function test in TypeScript:
function add(a, b) {
return a + b;
}
In this example, we have a basic add function that takes two arguments, adds them, and
returns the result. The test uses [Link] to verify whether the add function behaves as
expected. In this case, we expect add(2, 3) to return 5. If the condition is true, the test passes
silently. However, if the condition is false, the assertion fails, and an error message will be
thrown, alerting us to a potential issue in the function's behavior.
For instance, let's say we modify the test as follows:
The [Link] statement now checks whether add(2, 3) equals 11, which is
intentionally incorrect. When we run this code, it throws an error because the actual result of
add(2, 3) is 5, not 11. The console will display the following message:
In this context, the error is significant because it highlights how testing can catch unexpected
issues or incorrect assumptions early in the development process. By intentionally creating a
failing test, we're able to see how the [Link] mechanism works to flag errors. In real-
world scenarios, similar tests help ensure that changes to the code don't introduce bugs or
regressions. This simple example demonstrates how tests can act as safety nets, alerting
developers to potential problems before they affect the end users.
understanding these levels, you will be able to implement a comprehensive testing strategy
that ensures the reliability and performance of your applications.
Software testing is a multi-layered process designed to catch defects at different stages of
development. The main levels of testing are as follows:
• Unit testing
• Integration testing
• System testing
• Acceptance testing
Each level addresses specific testing needs and plays a crucial role in ensuring software quality.
While all four levels are important, in this chapter, we will focus on unit testing and
integration testing. These two are foundational for catching issues early in the development
process and ensuring that individual components, as well as their interactions, function
correctly.
Let's start with unit testing in the next section.
of the software are tested to validate that each unit performs as expected. A unit is the smallest
idx_a960c0db idx_ed3ca007
piece of code that can be tested in isolation. Depending on the software and architecture, a unit
might be a function, a method within a class, a class itself, or even a small module. The key idea
is that a unit should be independently testable without relying on external systems or
components.
This level of testing ensures that individual components of the software are reliable and
function correctly, laying the foundation for higher levels of testing such as integration and
system testing.
Chapter 5 114
Test case
A test case is a scenario where you check (test) a specific unit of your code (such as a function)
idx_dc2a716f
with a particular set of inputs (variables) to verify whether the code behaves as expected under
various conditions.
For example, let's say we have a function that adds two numbers as follows:
Now, we'll create two test cases to verify the behavior of the add function:
});
test('adds -1 + 1 to equal 0', () => {
// ....
});
Here, we've written two test cases. Basically, we are declaring what we are trying to verify. In
the first test case, we are saying we want to verify that when two numbers are added, the
function should return 4. In the second one, we are trying to verify that it also works well even
with negative numbers: -1 + 1 should be 0.
Now, the test case seems more like a declaration of what we want to verify. In the next
subheading, we will look at assertion, which does the actual verification.
Assertion
An assertion is a statement used in testing to check whether a particular condition is true. It's
idx_2f973174
like a statement that checks whether the code produces the desired result.
Continuing with the preceding example, let's add some assertions to our test cases:
The expect function is used for asserting whether the result matches the expected output.
Now that we understand what a test case is and how to assert that the code does what it's
supposed to, let's move to the next section, where we will look at mocks.
Mock
Imagine your code interacts with a database to retrieve user information or makes an API call.
In a unit test, you want to avoid actual interactions with such external systems to ensure that
your tests are reliable and repeatable. This is where mocking comes in.
A mock object acts as a stand-in for a real object that your code interacts with. It replicates the
idx_a1425c93
behavior of the real object in a controlled manner, enabling you to isolate the unit under test
and avoid external dependencies that might influence your test results.
Chapter 5 116
In this function, databaseService is an external dependency. During unit testing, there are
two primary approaches we can take to simulate its behavior.
Approach 1: manual mocks (dependency injection)
In this pattern, you define a mock object locally and pass it directly into your function as an
idx_efafb157
argument. This is often the cleanest method because it relies on standard JavaScript objects
and doesn't require any special manipulation of the module system. Here are the steps to
follow:
1. Import the function you want to test: Before we can test anything, we must bring the
function into our test file from our source code:
2. Create a mock object: Next, we create a plain object that "mimics" our database
service. We use [Link]() to create a mock function that returns a successful promise
with some sample data:
const mockDatabaseService = {
getUserById: [Link]().mockResolvedValue({
id: "123",
name: "John Doe",
email: "john@[Link]",
}),
};
3. Pass the mock into the function: When we call the getUserInfo function this time,
idx_9ef97f43
we pass the mock object as the second argument. This effectively "injects" our fake service
into the function instead of using a real one:
// Assertions
expect([Link]).toBe("John Doe");
expect([Link]).toHaveBeenCalledWith(userId);
});
will import that service directly into the file. To test this, you must tell your test runner to
intercept that import and replace the real file with a fake version. Here are the steps:
1. Import the modules and initialize the mock: First, we import the modules. It is
crucial to import the module you intend to mock so you can control it later in the test.
Immediately after the imports, we call [Link] to swap the real module for a fake
implementation:
// Import the actual module being mocked AND the function being tested
// Import the actual module AND the function being tested
import databaseService from '../src/databaseService';
import { getUserInfo } from '../src/getUserInfo';
// Tell Jest to replace the real file with this mock implementation
[Link]('../src/databaseService', () => ({
getUserById: [Link]()
}));
2. Define mock behavior and run the test: Inside the test block, we define what the
mock should return for this specific scenario. Then, we call our function. Notice that we
don't pass the service as an argument here—getUserInfo will automatically use the
mocked version we set up in step 1:
expect([Link]).toBe("Jane Doe");
});
expected:
// Verify that the internal service was called with the right ID
expect([Link]).toHaveBeenCalledWith("123");
Now that we've covered the basics of mocking and key terminology, let's move on to the next
crucial aspect: test runners. A test runner is essential for executing and managing your unit
idx_b956e9ef
tests. In the next section, we'll explore test runners in depth and discuss popular options in the
TypeScript ecosystem.
Test runners
Think of a test runner as the conductor of your testing orchestra. It automates running your
idx_360211a3
entire test suite (collection of test cases) efficiently. Imagine manually executing each test case
individually—tedious, right? Test runners streamline this process, providing clear reports on
successes and failures, allowing you to focus on writing and refining your tests.
The TypeScript ecosystem offers several robust test runner options. Here are some of the most
popular choices:
• Jest: This is a favorite choice known for its ease of use, rich features such as snapshot
idx_7f1b1963 idx_f8882c2d
• Vitest: Gaining popularity for its integration with the Vite build tool. Vitest offers a fast
idx_8a1c366c idx_242081cd
and efficient testing experience, leveraging modern JavaScript features and providing
seamless compatibility with TypeScript
Since we have an overview of the common test runners, let's see how you might choose which
one to use for your project:
119 Testing and Test-Driven Development
integration ensures a streamlined development workflow with fast test execution and
seamless TypeScript support.
In the next subsection, we are going to walk, step by step, through how you would set up Vitest
in a sample [Link] TypeScript project.
sample [Link] TypeScript project. You'll learn how to install the necessary dependencies,
configure TypeScript, and write your first unit tests using Vitest to ensure that your project is
ready for efficient testing. Let's get started:
1. Initialize your project: First, create a new [Link] project if you don't have one
already:
mkdir vitest-demo
cd vitest-demo
npm init -y
2. Install TypeScript and Vitest: Next, install TypeScript, Vitest, and the necessary types
for [Link]:
The command installs several packages essential for our project. First, we install
TypeScript since we're building a TypeScript project and need it for compiling and
type-checking our code. The Vitest package is our test runner, responsible for
handling and executing tests. ts-node is included so Vitest can run TypeScript files
Chapter 5 120
directly, eliminating the need to manually compile them to JavaScript. Lastly, @types/
node provides the necessary TypeScript definitions for [Link], ensuring proper type
support for Node's built-in modules.
3. Configure TypeScript: Initialize a basic TypeScript configuration by running the
following command:
This command generates a [Link] file. For a simple setup, you may not need to
change much, but make sure the [Link] includes at least the following
settings:
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node", "vitest/globals"]
}
}
The key part here is "types": ["node", "vitest/globals"], which ensures that
idx_91cba409
4. Create Vitest configuration: To make sure Vitest works with TypeScript and your
[Link] environment, create a [Link] file in the root of your project:
// [Link]
import { defineConfig } from 'vitest/config';
This configuration allows Vitest to use global functions such as test and expect
without needing to import them manually in each test file. By using ts-node, Vitest can
run TypeScript files directly, so you don't need to compile them to JavaScript first. The
configuration also specifies that the tests will run in a [Link] environment, which is
especially useful for server-side or utility functions. If you're testing browser-based
code, such as DOM manipulation, UI behavior, or frontend utilities (e.g., with React or
Vue), you can switch the environment to jsdom instead. Vitest supports multiple
environments, so choosing the right one ensures that your tests run in a context that
matches your code's runtime.
Please note, you also need to add the following to [Link]:
"type": "module"
This tells [Link] to treat .js and .ts files as ECMAScript Modules (ESM) by default,
idx_8a5c648f idx_88f89099 idx_f20faf72
// [Link]
export function square(n: number): number {
return n * n;
}
// [Link]
import { square } from './[Link]';
In this file, we start by importing the square function from the module we want to test.
Then, we use Vitest's test function to define two separate test cases. Finally, the
expect function is used to verify that the square function produces the correct output
based on the input values provided in each test case.
Note
You will notice we use the .js extension in the following code (from './
[Link]'). This is a requirement for modern [Link] projects to ensure the
idx_7c610583
runtime can find the file correctly. If you were using a frontend bundler (such
as Vite or Webpack), these extensions are usually handled for you, but in a
standard [Link] environment, being explicit is the safest approach.
6. Add a test script to [Link]: Now, let's add a script to [Link] to run
Vitest:
{
"scripts": {
}
}
7. Run the tests: Finally, run your tests by executing the following command:
Note
Vitest requires a modern version of [Link]. If you see an error such as SyntaxError:
Unexpected token '=', ensure that you are running [Link] 20 or later. Older [Link]
versions do not support some of the JavaScript features used internally by Vitest.
Figure 5.1 — Successful execution of unit tests using the Vitest runner
To better understand how to write and test a TypeScript function, let's consider a simple
example. Here, we define a function called square that calculates the square of a given number:
The following code block shows what a unit test for the preceding function looks like:
// [Link]
import { square } from './[Link]';
square function correctly calculates the square of 3, then this test will pass. If the function
idx_8d1d7445
Just like the previous test, this test uses the test function to run a test case, but this time, it
passes -4 as the input to the square function and expects the result to be 16.
Now that we've understood what a unit test would look like, let's find out what the overall
purpose of the test file is.
These unit tests, specifically the ones demonstrated earlier—(test('calculates the square
of 3 correctly', ...) and test('calculates the square of -4 correctly', ...))—
ensure that the square function works correctly for both positive and negative numbers. By
testing with values such as 3 and -4, these test cases verify that the function accurately
calculates the square of any given number.
If the function passes these tests, it confirms that the implementation is correct for these
idx_d15a9c3d idx_bbd5ef93
specific scenarios. Conversely, if any test fails, it highlights a potential bug in the square
function that requires further investigation.
125 Testing and Test-Driven Development
Now that we have an overview of what a unit test is, let's move on to integration testing in the
next section.
based applications, it plays a complementary role alongside static type checking. While
TypeScript helps detect type-related errors at compile time, integration testing ensures that
independently correct components interact correctly at runtime.
This section provides a comprehensive guide to integration testing in the context of TypeScript
applications, covering its fundamentals, importance, common approaches, and practical
implementation using popular testing tools.
and tested as a group. The main purpose of this testing is to expose faults in the interaction
between integrated units. In the context of software development, a unit could be a function, a
module, or a class.
When we write our code in TypeScript, a statically typed superset of JavaScript, we add a
idx_4ccb10f4
robust type-checking layer to our code base. This helps catch errors at compile time, making
our code more predictable and easier to debug. However, type checking is not enough to ensure
the correctness of our code. This is where integration testing comes into play. It helps us ensure
that different units of our application work together as expected.
between modules. While unit tests ensure that individual components work in isolation,
integration tests make sure that these components work together as expected.
For example, consider an e-commerce application. A unit test might check whether the Add to
Cart function works correctly (i.e., it adds an item to the cart). An integration test, on the other
hand, would check whether the Add to Cart function works correctly in conjunction with the
inventory management system, ensuring that it adds an item to the cart and simultaneously
updates the inventory. This highlights the need to verify how modules interact in real-world
scenarios.
Additionally, integration testing verifies data flow across different components, ensuring that
the output of one module is correctly passed as input to another. This is especially crucial in
complex systems where data dependencies exist between modules.
Chapter 5 126
Integration tests also help catch issues related to external systems or services. In many modern
applications, components interact with databases, APIs, or third-party services. Integration
testing ensures that these interactions function correctly under real-world conditions.
Finally, integration testing helps uncover unexpected behavior that may arise when multiple
components are combined, providing a more realistic assessment of the application's
functionality in its intended environment.
idx_411ddaa9
Now that we've learned about what integration testing is and why we need it, let's look into
the types of integration testing.
conducting the tests. This approach is straightforward and doesn't require any particular order
of integration for the modules.
Here are its advantages:
• It is simple to implement as there's no need for stubs or drivers (temporary modules for
idx_def5dc00
testing)
• It is suitable for small systems where the modules are heavily interdependent
Here are its disadvantages:
• If an issue arises, it's challenging to identify the module causing the problem due to the
idx_53bf69fd
Incremental approach
The incremental approach involves integrating and testing two modules at a time. After
idx_235a5a83 idx_626fd923
testing, another module is integrated, and the testing process repeats. This approach can be
further divided into three types: top-down, bottom-up, and sandwich/hybrid.
Let's delve in; we will start with the top-down approach.
127 Testing and Test-Driven Development
Top-down approach
In the top-down approach, testing starts from the top of the module hierarchy (the "parents")
idx_fed47c5f
and moves toward the bottom (the "children"). Because the lower-level modules might not be
idx_a73ff6ec
developed yet, we use stubs to simulate their behavior. A stub is a temporary, simplified idx_11dd284e
implementation of a module that stands in for the real one. For instance, if you are testing a
high-level Checkout module that depends on a "tax calculator" that hasn't been built, you
would use a stub that simply returns a fixed value, such as 10.00, every time. This allows you
to verify that the Checkout module correctly receives and displays data without needing the
actual calculation logic to exist.
Here is a quick example of a stub in code:
// We can now test the Checkout logic even without the real Tax engine
const total = checkout(100.00, taxServiceStub);
[Link](total); // Should be 110.00
bottom of the module hierarchy and moves upward toward higher-level modules. Because the
higher-level modules may not yet be implemented, drivers are used to simulate their behavior.
A driver is a temporary test module that invokes lower-level components and supplies test
idx_d764d389
data to them, acting as a substitute for the real higher-level module. For example, if a database
access module is ready but the service or controller that normally calls it has not yet been
implemented, a driver can be written to directly call the database functions and verify their
behavior.
Chapter 5 128
if ([Link]) {
[Link]("Success: The bottom-level module works!");
}
};
testDriver();
approaches. It aims to leverage the advantages of both methods while minimizing their
idx_60cb4f71
disadvantages.
Here are its advantages: idx_4faa2c15
• Comprehensive as it tests from both the top and bottom of the module hierarchy
• Reduces the need for stubs and drivers
Here is its disadvantage:
idx_a9d51e20
Now that we know what the types of integration testing are, let's explore how to perform one.
You can perform the test manually or automate it. In the next section, we will consider these
two options.
129 Testing and Test-Driven Development
of automation tools. Testers manually follow the steps defined in the test cases and record the
outcomes. This approach can be labor-intensive and prone to human error, but it also offers
certain benefits.
Here are its advantages:
• Flexibility:
◦ Testers can quickly adapt to new or changing requirements without needing to
idx_15afb5c8
• Time-consuming:
◦ Manual testing is slower compared to automated testing, especially for large
and complex applications
◦ Repetitive test cases are tedious and time-consuming to execute manually
Chapter 5 130
• Inconsistent:
◦ Manual testing is subject to human error and inconsistencies
◦ Different testers might execute the same test case differently, leading to varied
results
• Scalability:
◦ Scaling manual tests to cover extensive test suites and multiple integrations can
be challenging
◦ It requires significant human resources, which may not be feasible for
continuous integration and deployment
idx_253d6f15
So, when should you use manual integration testing? Here's when:
idx_5d2407f8
application
While manual integration testing provides flexibility and valuable insights during early
development stages, it also has limitations in terms of scalability, consistency, and efficiency.
As applications grow more complex and demand frequent updates, manual testing may
become time-consuming and error-prone.
To address these challenges, automated integration testing can be employed to enhance speed,
accuracy, and scalability. By automating repetitive test cases, testers can focus on more critical
areas, and teams can ensure continuous feedback throughout the development cycle.
This approach helps to run tests more efficiently and consistently, providing quick feedback on
the integration points of the application.
Here are its advantages:
• Speed and efficiency:
◦ Automated tests can be executed much faster than manual tests, making them
idx_e1dec7c9 idx_6f769132
Now, when should you employ automated integration testing? Let's find out.
Chapter 5 132
completed tests to ensure that recent changes or additions to the code have not
negatively affected the existing functionality of the system. It is typically used when
new features, bug fixes, or updates are introduced to a system to verify that these
modifications do not introduce new issues or regressions. Automated integration
testing is indispensable in regression testing as it allows for frequent and systematic
execution of test cases, ensuring that any new modifications do not disrupt or degrade
the system's existing functionality.
• CI/CD: In a CI/CD environment, automated integration testing plays a pivotal role. It
idx_a6e25626
Create, Read, Update, Delete) functionality and implement integration testing using Vitest idx_46c906c3
and Supertest. This walkthrough covers every step, from project initialization to running the
idx_131feb06
mkdir blog-app
cd blog-app
npm init –y
The npm init -y command generates a [Link] file. Because we are using
modern [Link] and Vitest, you must add "type": "module" to this file.
Why "type": "module"? This setting tells [Link] to treat your files as ESM. Without
this, you won't be able to use modern import and export statements in your .ts files.
133 Testing and Test-Driven Development
Vitest (our test runner), and Supertest, which allow us to simulate HTTP requests
against our API without needing to start a live server.
3. Configure TypeScript: Create a [Link] file in the root directory. To support
modern ESM rules and ensure compatibility with [Link] 20 and later, use the
following configuration:
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"verbatimModuleSyntax": true
},
"include": ["src/**/*", "tests/**/*"]
}
4. Create the project structure: Organize your source code into logical layers to separate
concerns:
5. Create the model: The model defines our data structure. Create src/models/[Link]:
content)
◦ The posts array: Acts as our temporary database stored in the server memory
◦ getNextId(): A helper function that returns the current ID and increments it for
the next post
6. Create the service layer: The service layer handles the business logic. Create src/
services/[Link]:
export const getPostById = (id: number) => [Link](p => [Link] === id);
export const updatePost = (id: number, title: string, content: string) =>
{
const post = getPostById(id);
if (post) {
[Link] = title;
[Link] = content;
return post;
}
return null;
};
135 Testing and Test-Driven Development
Let's go over the code to get a better understanding of what we just did:
◦ getPosts(): Simply returns the entire array of blog posts currently stored in our
in-memory database.
◦ getPostById(id): Uses the array find method to retrieve a specific post by its
unique ID.
◦ createPost(title, content): Generates a new ID using our getNextId
helper, creates a Post object, and adds it to our posts array. Notice that we
return the newly created post so the controller can send it back to the user.
◦ updatePost(id, title, content): First, locates the existing post. If found, it
updates the title and content properties directly within the array and returns
idx_2d736ca0
Don't Repeat Yourself (DRY). The controllers don't need to know how the data is
stored or deleted; they simply tell the service what to do.
7. Implementing the controller: The controller handles the HTTP request/response
cycle. Create src/controllers/[Link]:
In this code, we set up the routes for the blog API using the Express router. Each route is
mapped to a specific controller function. The GET /posts route calls getAllPosts to
retrieve all posts, while GET /posts/:id calls getPost to retrieve a post by its ID. The
POST /posts route handles creating a new post by calling createNewPost. The PUT /
posts/:id route updates an existing post with updateExistingPost, and the DELETE /
posts/:id route deletes a post by calling deleteExistingPost. Finally, the router is
exported for integration into the main application.
137 Testing and Test-Driven Development
Finally, set up the main entry point and initialize the Express app in src/[Link]:
1. Initialize the Express application: Import express from 'express':
2. Add the integration test: Now, we create a single test file to ensure that all these layers
work together. Create tests/[Link].
3. Initialize the test file and create a post: First, we import our testing tools and the
application instance. We start by testing the creation of a new blog post using the POST
/posts endpoint. The following are the different test cases we'll use to verify each part
of the blog API:
expect([Link]).toBe(201);
postId = [Link];
// ... The GET request verification will be added here
})
// ... Additional test cases for Update and Delete will follow
})
In this block, we send a POST request with a title and content. The test ensures that the
post is successfully created with a 201 status code. We then capture postId returned by
the server so we can use it in subsequent tests.
4. Retrieve the created post: Within the same test case, we immediately verify that the
post we just created can be retrieved using the GET /posts/:id endpoint:
Here, we retrieve the post by postId we saved earlier. We check that the response
idx_37a60d72
returns a 200 OK status code and that the title matches what we originally sent,
confirming that the data successfully traveled from the controller to the service layer
and back.
5. Update an existing post: Next, we test the PUT /posts/:id endpoint to verify that we
can modify an existing post's data:
expect([Link]).toBe(200);
expect([Link]).toBe('Updated');
expect([Link]).toBe('This content has been modified.');
});
This test sends a PUT request to update the title and content. We verify that the status
code is 200 and that the response body reflects the new Updated title, ensuring that our
service layer's update logic is working correctly.
6. Delete the post and verify its removal: Finally, we test the DELETE /posts/:id
endpoint to ensure that posts can be removed, and we verify that a deleted post can no
longer be retrieved:
In this final step, we call the DELETE endpoint and check for a 204 No Content status
idx_48cca927
process. Instead of writing code first and then creating tests afterward (as we did earlier), TDD
focuses on writing the tests before the actual code. This approach helps shape the development
process and ensures that functionality is tested from the very beginning. Let's explore some key
aspects of TDD.
TDD involves a three-step cycle: Red, Green, and Refactor. Let's delve into these steps in more
detail:
1. Red: The first step involves writing the test. You think of the expectations for the code
idx_9d9a3a41
or feature you are working on, and you write the test case for it. Think of this as your
expectation of what the code should do. At this point, you run your test, and it should
fail because there isn't any code yet.
2. Green: Once you've completed the first step, the next step involves writing just enough
idx_82ddb0f1
In summary, TDD is more of a philosophy that states that tests should drive your coding
process. It improves how you think about the features you are working on and the code you are
writing. Now, let's look at some best practices for TDD and for writing tests in general.
clear, maintainable, and reliable. In this section, we'll explore key practices such as writing
effective test names and descriptions, keeping tests simple and focused, and using mocking
and stubbing techniques. Let's begin:
• Writing effective test names and descriptions: Test names should be descriptive and
clearly state what functionality is being tested. A well-named test allows others to
understand the purpose of the test without needing to dig into the code. For example, if
you're testing a function that adds two numbers, a good test name might describe the
expected behavior, such as "correctly adds two numbers."
Here is an example of how to write a descriptive test for a simple addition function:
describe('Calculator', () => {
it('correctly adds two numbers', () => {
const result = [Link](2, 3);
expect(result).toBe(5);
141 Testing and Test-Driven Development
});
});
The code begins with a describe block, which groups related tests under the label
Calculator, representing the feature being tested. Inside, the it block defines a specific
test case with the name correctly adds two numbers, clearly stating the expected
outcome. The test calls the [Link]() function with the arguments of 2 and 3,
and the expect(result).toBe(5) statement verifies that the result returned by the add
function equals 5. If the result matches, the test passes; otherwise, it fails. This
structure ensures that the test case is both descriptive and easy to understand.
• Keeping tests simple and focused: Each test should focus on a single functionality.
This makes it easier to identify which specific functionality is broken when a test fails.
For instance, if you have a function that both adds and subtracts numbers, you should
have separate tests for addition and subtraction.
The following is an example of a test suite for a Calculator object using a testing
framework such as Jest. This suite contains two individual tests—one for the addition
functionality and another for the subtraction functionality:
describe('Calculator', () => {
it('correctly adds two numbers', () => {
const result = [Link](2, 3);
expect(result).toBe(5);
});
The code defines a test suite for the Calculator object, grouping related tests under the
label 'Calculator' using the describe function. Within this suite, the it function
specifies individual test cases, such as checking the addition functionality with
[Link](2, 3), which stores the result in a variable. The
expect(result).toBe(5) assertion verifies that the addition result equals 5, indicating
a passing test if true. Similarly, another test case checks the subtraction functionality
using [Link](5, 2), with the result being verified by the
Chapter 5 142
• Using mocking and stubbing techniques: Mocking and stubbing are techniques that
allow you to simulate functionality. This is useful when the functionality you're testing
depends on other functions or external systems. For example, if you're testing a
function that fetches data from an API, you can use a mock to simulate the API
response:
describe('ApiService', () => {
it('fetches data from the API', async () => {
const data = { data: { results: [1, 2, 3] } };
[Link](data);
In this code, axios is imported, and then [Link]('axios') is used to mock the
axios library. This means that instead of making a real API call, we simulate the
behavior of the [Link] function. In the test, the
[Link](data) method is used to simulate a successful API call
that resolves with predefined data, ({ results: [1, 2, 3] }). The
[Link] function is then called, and the test verifies that it correctly
returns the expected results, ([Link]). This approach ensures that the
test doesn't depend on external systems such as APIs, making it faster and more
reliable.
143 Testing and Test-Driven Development
• Using the AAA pattern: The Arrange-Act-Assert (AAA) pattern helps structure your
idx_7a048954
tests in a clear and logical way, making them more readable and maintainable. The
idx_741749c6
Arrange step sets up any necessary preconditions, the Act step executes the
functionality you're testing, and the Assert step verifies that the functionality behaved
as expected. Here is an example of using this pattern in a test for a Calculator object:
describe('Calculator', () => {
it('correctly adds two numbers', () => {
// Arrange
const num1 = 2;
const num2 = 3;
// Act
const result = [Link](num1, num2);
// Assert
expect(result).toBe(5);
});
});
In the preceding code, the test is structured following the AAA pattern. In the Arrange
step, the num1 and num2 variables are initialized with values of 2 and 3, respectively.
These values represent the input for the test. In the Act step, the
[Link](num1, num2) function is called, which adds the two numbers. The
result of this operation is stored in the result variable. Finally, in the Assert step, the
expect(result).toBe(5) statement checks whether the result of the addition is 5. If
the result is correct, the test passes; otherwise, it fails. This pattern keeps the test well-
organized, making it easy to follow the flow of setup, execution, and validation.
• Avoiding test interdependence: Each test should be independent and not rely on the
state set by previous tests. This ensures that tests can be run in any order and that
they're not affected by side effects from other tests.
• Testing edge cases: Don't just test the happy path. Make sure to write tests for edge
cases, such as invalid inputs, empty states, and extreme values. This helps ensure your
code is robust and can handle unexpected situations gracefully.
• Keeping your tests DRY: Just like in your production code, avoid duplicating code in
your tests. If you find yourself writing the same setup or assertion code in multiple
tests, consider using helper functions or before/after hooks.
idx_4f515345
Chapter 5 144
By following these best practices—writing clear and descriptive test names, keeping tests
simple and focused, using mocking and stubbing techniques, structuring tests with the AAA
pattern, avoiding test interdependence, testing edge cases, and keeping your tests DRY—you
can ensure that your test-driven development process in TypeScript is both efficient and
effective. These strategies will help maintain clean, reliable, and easy-to-maintain code as your
project grows.
Summary
In this chapter, we covered essential aspects of building and testing a TypeScript application.
We started by setting up a TypeScript project, configuring necessary dependencies such as Jest
and Supertest, and ensuring smooth integration of TypeScript and Jest for development and
testing.
We then focused on writing integration tests using Jest and Supertest. To illustrate these
concepts, we used an example involving an Express application to test CRUD operations. This
example demonstrated best practices for organizing and testing applications with TypeScript.
By following along, you have learned how to set up a TypeScript project, define models,
controllers, and routes in an Express application, and create robust integration tests to ensure
that your API functions correctly.
In the next chapter, we will explore error handling and debugging with TypeScript. You will
learn how to implement robust error handling, effectively debug TypeScript code, use
TypeScript's type system to catch errors early, and utilize tools and techniques to improve error
reporting and debugging. This will enhance your ability to maintain and troubleshoot
TypeScript applications, making your code more resilient and easier to manage.
145 Testing and Test-Driven Development
Note: Keep your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
6
Error Handling, Debugging, and
Security Best Practices
Developing robust and reliable software requires mastering the art of error handling and
debugging. These are the critical components of building robust TypeScript applications.
Understanding how to effectively manage errors, leverage debugging tools, and implement
security best practices ensures that applications are reliable, maintainable, and secure.
In this chapter, you'll learn essential techniques and strategies for handling both synchronous
and asynchronous errors in TypeScript. Synchronous errors occur during the execution of a
specific block of code and are thrown immediately, such as syntax errors or reference errors.
Asynchronous errors, on the other hand, occur outside the main execution flow—typically in
callbacks, promises, or async functions—making them more challenging to handle.
We will explore the use of different error types and learn how to utilize debugging tools to
identify and resolve issues efficiently. Additionally, we will cover common error patterns and
solutions, and essential security practices to safeguard your TypeScript applications.
In this chapter, we will cover the following:
• Learning strategies for error handling
• Handling synchronous errors in TypeScript
• Strategies for dealing with asynchronous errors (e.g., promises, async/await, and try/
catch)
By the end of this chapter, you will be equipped with the knowledge to handle errors
gracefully, debug effectively, and implement security measures to protect your applications.
Technical requirements
To follow along with this chapter, you'll need the following tools installed on your system:
• [Link] (v16 or later): Required to run and compile TypeScript applications.
• TypeScript (v5 or later)
• Visual Studio Code (VS Code): Recommended editor with built-in debugging support.
You can download the example project and code for this book by following the instructions in
the Download the example code files section in the Preface of this book. This chapter's code files
are included in the downloadable code bundle.
The GitHub repository contains all sample projects, including examples for error handling,
debugging with VS Code, source maps, breakpoints, and implementing security best practices.
into how to handle errors, it's essential to first understand the different types of errors you
might encounter and recognize common patterns that can lead to these errors. By doing so,
you'll be better equipped to identify, debug, and resolve issues efficiently. Let's break down this
heading into two key components: introducing error types and common error patterns.
runtime errors, and logical errors. These types are common across most programming
languages.
In TypeScript, additional error types arise due to its unique features. These include type errors,
compilation errors, and errors specific to asynchronous or synchronous operations.
Understanding these categories is essential for diagnosing and resolving issues in your code
effectively. Let's go over them one after the other.
Syntax errors
Syntax errors are mistakes in the code that violate the rules of the TypeScript language, such
idx_ddfe6205 idx_f018690e
as mismatched parentheses or invalid function names. These errors prevent your code from
compiling, and the TypeScript compiler catches them during the compilation process. Let's see
an example and how you would identify and solve them: idx_fd8b94d9
const x = 2;
if (x > 5 { [Link]("x is big");}
Do you notice anything wrong? Well, if you copy that into a random TypeScript file, for
idx_a0acdbd0
example, an [Link] file, you should see a red squiggly line, as shown in the following figure:
Figure 6.1 — VS Code editor highlighting a syntax error with a red squiggly line under the problematic code,
displaying the hover tooltip: ')' expected ts(1005)
What's wrong? In the previous code, we have a condition that checks whether x is greater than
5. However, we are missing the closing parenthesis. This is immediately apparent because the
editor (e.g., VS Code) highlights the issue with a red squiggly line under the curly braces. When
we hover over the red line, we can see the following error message:
Additionally, if you check your terminal while running npx tsc --w, you'll see more details
about the error. This is because the missing parenthesis causes a syntax error, which prevents
TypeScript from compiling the code, as shown in the following figure:
Figure 6.2 — Terminal output from the TypeScript compiler showing error TS1005 for a missing closing parenthesis
in [Link], including line references and parser details
Chapter 6 150
Now that we know what syntax errors are, let's look at how to identify and fix them.
idx_95afa9df
Syntax errors are typically highlighted in modern code editors (such as VS Code), making them
idx_f5ef4361
easy to spot. These errors prevent your code from compiling, and most editors display error
messages or underline the problematic code.
To resolve them, carefully review the error messages provided by the TypeScript compiler or
your editor. These messages often include details about the issue and its location.
Here's what the correct code looks like:
const x = 2;
if (x > 5) {
[Link]("x is big");
}
If we copy that and paste it into our editor, the error should be gone. See the following figure:
Figure 6.3 — VS Code editor displaying the corrected code with the missing parenthesis added, showing no red
squiggly lines indicating a successful syntax fix
Now that we've fixed the error as shown in the preceding figure, you notice that the red
squiggly line is gone. If we proceed to check out the terminal, we would see that the code
compiles successfully and there are no errors, as seen in the following figure:
Figure 6.4 — Terminal output after fixing the syntax error, showing successful compilation with zero errors and
ongoing file change monitoring
What you just saw with syntax errors stopping the code from compiling is one of the beauties
idx_8923a15d
of TypeScript, as it helps us catch bugs early. Now that we know about syntax errors, let's jump
idx_b7a9f381
Type errors
Type errors occur when a value is used in a way that is incompatible with its declared or
idx_f3578860 idx_db20807e
inferred type. Unlike syntax errors, type errors do not break the structure of your code, but they
violate the rules of how values are supposed to behave. One of TypeScript's greatest strengths
is that it detects these issues during compilation, allowing you to fix them before the program
runs.
Let's look at some common examples of type errors and how to resolve them:
idx_4fe5a3d1
To fix this, simply assign a value that matches the declared type:
• Calling a method that doesn't exist on a type: Another common type error occurs
when calling a method that does not exist on the given type:
In the preceding code, we are trying to call the push method on a variable of the string
idx_510c5916
type. However, push is not a method on the string type; it's a method on the array
type. As a result, you will get an error:
Here, push() is an array method, not a string method. Since user is declared as a string,
TypeScript correctly reports an error.
To resolve this, ensure that the variable is declared with the appropriate type:
By declaring user as an array of strings (string[]), we can safely use array methods
such as push().
• Accessing a property on a value that is potentially null or undefined: TypeScript
also warns you when a value might not exist at runtime:
idx_e3ea23a2
interface User {
firstName: string;
lastName?: string;
}
Because user may be null, TypeScript prevents direct property access. You must first
check that the value exists:
if(user) {
[Link]([Link]);
}
[Link](user?.firstName);
Both approaches ensure that you only access properties when the value is defined.
Type errors are typically easy to spot because modern editors highlight them instantly using
idx_ea63fc74
visual indicators such as red squiggly lines. The TypeScript compiler also provides detailed
error messages explaining what went wrong and where.
To resolve type errors effectively, you can do the following:
• Review compiler or editor messages carefully
• Ensure that variable, parameter, and return types match their intended values
153 Error Handling, Debugging, and Security Best Practices
• Use type guards (typeof, instanceof, or custom checks) when working with union or
unknown types
• Enable strict mode in your TypeScript configuration for stronger guarantees
Type errors are especially valuable because they catch problems before your code runs. This
idx_390cc001
shifts failures earlier in the development cycle, making them easier and cheaper to fix.
Now that we've seen how TypeScript catches mistakes before execution, let's move on to
runtime errors—issues that only appear when your program actually runs.
Runtime errors
Runtime errors occur when your code is syntactically correct and passes TypeScript's static
idx_330b6fed idx_001291e0
type check, but an issue arises during the execution of the program. These errors are often
related to unexpected runtime conditions, rather than simple type mismatches.
TypeScript can prevent many common mistakes at compile time. However, it cannot eliminate
all runtime failures—especially when working with dynamic data, external APIs, user input, or
values that are typed loosely (for example, using any or type assertions).
Common causes of runtime errors include the following:
• Accessing a property on undefined or null
• Assuming that external API responses conform to a specific structure
• Performing operations on unexpected values
• Bypassing type safety using any or forced type assertions
Let's see some examples:
idx_70567506
type User = {
name: string;
};
Although this code compiles, it fails at runtime because undefined does not have a name
idx_1cae5f52
property.
Chapter 6 154
This example highlights an important distinction: static type checking improves safety, but it
cannot guarantee correctness when runtime assumptions are violated. When dealing with
external APIs or untrusted input, additional validation is necessary to ensure reliability.
idx_87427fb8
Now that we know what runtime errors are, let's look at how to identify and fix them.
How to identify and resolve runtime errors
Unlike syntax errors, runtime errors will only appear when you run your application. These
idx_c5845b94
errors are typically logged to the console and can cause your program to crash or behave idx_bb095058
unpredictably. To resolve runtime errors, you'll need to debug your code by using tools such as
breakpoints, console logs, or debuggers. These tools help trace the execution flow and pinpoint
where the error occurs, allowing you to fix the issue and prevent it from affecting your
program's functionality.
This section covered runtime errors, their identification, and their resolution. You learned how
to use debugging techniques to trace and correct runtime issues in your applications.
Next, we will explore logical errors—errors that affect the correctness of your program's output
despite no syntax or runtime issues.
Logical errors
These occur when the code runs without crashing but produces incorrect results due to flaws
idx_c40d5d87 idx_bf2313d2
in the logic. Logical errors are the trickiest to identify because they occur when your code runs
without any syntax or runtime errors but doesn't produce the expected outcome.
Let's see some examples:
idx_59bc103c
In this example, the error occurs because the calculation doesn't account for the correct
order of operations. The subtraction and multiplication need to be grouped properly to
idx_387aa93d
• Misusing conditionals:
In this example, the error occurs because the single equals sign (=) is used for
assignment, not comparison. This will always evaluate to true (or the assigned value),
not the intended comparison. The correct approach should be as follows:
you must test your code thoroughly and compare the actual output against what you expect.
Look for discrepancies by examining how your code handles various inputs, especially
boundary values—the smallest and largest valid inputs in a range. These values often reveal
idx_6213a201
hidden flaws in logic that typical test cases might miss. For example, if a function accepts ages
between 18 and 65, testing with 17, 18, 65, and 66 helps ensure the logic correctly handles the
limits.
Once identified, resolve the error by carefully analyzing the faulty logic. Use test cases,
idx_8ce5c71b
particularly edge cases, to validate your corrections. To prevent these errors proactively,
consider adopting test-driven development (TDD), where you write tests before the code.
idx_02d8e3d0
This approach forces you to think about the desired outcomes and logic upfront, significantly
reducing the chance of logical errors.
Let's go over a quick example.
Consider a TypeScript function designed to calculate the average of an array of numbers:
idx_54a27724
When testing this function with calculateAverage([1, 2, 3, 4]), you expect an average of
2.5, but get NaN. To identify the issue, you review the logic and add debugging logs as follows:
Running this with [1, 2, 3, 4] logs an undefined value at index 4, revealing that the loop
iterates one step too far (i <= [Link] includes an out-of-bounds index). See the
following figure for reference:
Figure 6.5 — Debugging logs from a TypeScript function with a logical error, showing an undefined value at index
4 due to an off-by-one loop condition when calculating the average of [1, 2, 3, 4]
The correct logic should use i < [Link]. See the correct full code here:
idx_c049a7a5
In the preceding example, we demonstrated how debugging combined with TypeScript's type
safety and attention to edge cases can help resolve logical errors and improve code reliability.
Now, you have learned about logical errors and strategies for identifying and correcting them.
Logical errors require careful code review, testing, and consideration of edge cases to resolve, as
they often do not produce explicit error notifications. TypeScript's type system can also help by
catching potential issues during development.
strategies for managing them effectively. The goal is to ensure that errors don't negatively
impact the user experience by breaking functionality or preventing proper use of your
application—even when things don't go as planned.
To keep things structured, we'll approach error handling from two key angles:
• Synchronous errors: Issues that occur during sequential code execution, such as logic
idx_cee6f37c
or type-related problems
• Asynchronous errors: Challenges that arise from operations running independently,
idx_7870458c
We'll explore both approaches in depth, starting with strategies for handling synchronous
errors.
parser starts at the top of a module (such as [Link]) and reads the code line by line,
executing each statement in sequence. This linear flow is predictable, but it also means that if
idx_2463d115
something goes wrong along the way—such as a bad calculation or an invalid value—it can
break the entire program unless it's handled properly.
Errors in synchronous code can happen at any point, from simple logic bugs to unexpected
input values. That's why it's important to have clear strategies in place to catch and manage
them—preventing crashes and ensuring that your application stays stable and user-friendly.
In this section, we'll explore the following:
• What synchronous errors are
Chapter 6 158
line, in the order it's written. These errors typically arise from incorrect logic, faulty
assumptions, or unexpected input data. If not handled properly, synchronous errors can cause
your application to fail or produce incorrect results.
To demonstrate, let's take a look at a small web-based application powered by TypeScript ( as
idx_688bb225
shown in the following figure). It performs a very simple operation: calculating the square of a
number.
Figure 6.6 — Basic HTML structure for a square calculator app, featuring an input field for number entry, a
Calculate Square button, and a result display area, linked to a JavaScript file for functionality
You enter a number, click on the Calculate Square button, and the app displays the square of
idx_fd14831b
the number.
Here's the basic HTML structure that powers our frontend:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Synchronous Error Handling</title>
</head>
<body>
<h2>Calculate the square of:</h2>
<input type="text" id="numberInput" placeholder="Enter a number" />
<button onclick="handleCalculation()">Calculate Square</button>
<p id="result"></p>
<script src="[Link]"></script>
</body>
</html>
159 Error Handling, Debugging, and Security Best Practices
Nothing too fancy—just an input field, a button, and a paragraph to show the result. Now, let's
see what the TypeScript code that powers our web app looks like. In the [Link] file, we
have the following code:
• calculateSquare: Takes a number, checks whether it's valid, and returns its square, or
throws an error if invalid.
When we enter a valid number, say 5, we get a result of 25, displayed in green. Everything
idx_ba62c97b
But what could go wrong? Well, for numeric inputs, the code works exactly as expected.
However, if a user enters a non-numeric value, such as text, they won't see any result in the
browser. The app seems to have crashed. If we inspect the console (using Chrome DevTools),
we can see what happened, as shown in the following figure:
Figure 6.8 — Displaying an error message in the browser console for invalid input
In the console, we find the following error message: Please enter a valid number.
This error is thrown by the calculateSquare() function. Because the code is running
synchronously, once the error is thrown, execution stops immediately, and the part of the
code that updates the result is never reached. While this is a trivial example, in a real-world
idx_0ec4fa22
In this section, we'll resolve the issue by using a try-catch block to handle potential errors
gracefully. The solution involves updating the handleCalculation() function as follows:
try {
const result = calculateSquare(inputValue);
[Link] = "green";
[Link] = `Square: ${result}`;
} catch (error: unknown) {
[Link] = "red";
if (error instanceof Error) {
[Link] = [Link];
} else {
[Link] = "An unexpected error occurred.";
}
}
}
Figure 6.9 — Displaying a clear error message as feedback to the user when a text is entered
using it in your functions or calculations. Input validation ensures that the data your functions
receive meets the expected criteria, reducing the likelihood of errors.
Continuing with our example, we will now extend the handleCalculation function as follows:
// Input validation
if (!inputValue) {
[Link] = "red";
[Link] = "Input cannot be empty.";
return;
}
163 Error Handling, Debugging, and Security Best Practices
if (!/^-?\d*\.?\d*$/.test(inputValue)) {
[Link] = "red";
[Link] = "Please enter a valid number.";
return;
}
In the updated code, we introduced two new validation checks using simple if conditions.
idx_c22b6f75
The first check verifies whether the input field is empty. If it is, the user immediately sees an
error message—Input cannot be empty. The return statement ensures that the function
exits early and does not proceed any further:
if (!inputValue) {
[Link] = "red";
[Link] = "Input cannot be empty.";
return;
}
The second check validates the format of the input using a regular expression:
if (!/^-?\d*\.?\d*$/.test(inputValue)) {
[Link] = "red";
[Link] = "Please enter a valid number.";
return;
}
This regular expression ensures that the supplied input is a valid number—it allows for
idx_5fd71515
optional negative signs and decimal points, covering a broad range of numeric inputs.
Chapter 6 164
Of course, we keep the try-catch block as a fallback to catch any unexpected errors that might
still occur during the actual calculation process.
Why is this approach better?
We want to catch issues as early as possible, before they propagate into larger problems. Early
validation greatly improves user experience by giving instant feedback and preventing
application crashes.
While our example is simple, imagine real-world applications: think about a signup form that
collects a user's email or age, or a financial app that processes transaction amounts. Allowing
users to enter anything without validation could lead to serious errors—even security
vulnerabilities. Proper input validation isn't just about avoiding bugs; it also helps protect
applications from risks such as cross-site scripting (XSS) attacks.
idx_d6129b63
In short, good input validation is one of the first lines of defense for building stable, secure, and
user-friendly applications.
So far, we've explored the concept of synchronous errors—errors that happen during the step-
by-step execution of our code. We showed the dangers of unhandled synchronous errors,
introduced a try-catch block to handle them, and took things a step further by adding input
validation. We used a simple, practical example to make these concepts clear.
In the next section, we'll move on to errors in asynchronous code—where things don't happen
immediately, and errors become a bit trickier to catch.
perform multiple tasks at once, such as fetching data from an API, reading files, or interacting
with a database, without blocking the main execution thread. However, handling errors in
asynchronous code can be more complex than in synchronous code. This section will guide you
through the key concepts and techniques you need to manage asynchronous errors effectively.
We will explore what asynchronous errors are, why they occur, and the best practices for
handling them using promises, async/await syntax, and other essential techniques. By the end
of this section, you'll have a solid understanding of how to keep your asynchronous code
resilient and error-free.
thread. While waiting for these tasks to finish, the program continues doing other things.
Examples include fetching data from the internet, reading files, or setting timers.
165 Error Handling, Debugging, and Security Best Practices
Because these operations are not completed instantly, the errors they produce might not occur
at the same time as the function call. This delayed error occurrence makes it necessary to
handle errors differently than in synchronous code.
Here is an example of asynchronous code:
fetchData("[Link]
In this example, the fetch function is an asynchronous operation. If something goes wrong
(e.g., the network is down or the URL is incorrect), an error might occur, but only after the
fetch request is initiated.
• Ensures application stability: Proper error handling prevents your application from
crashing when an asynchronous operation fails
• Improves user experience: Users receive meaningful feedback when something goes
wrong, instead of being left with a frozen or unresponsive application
• Facilitates debugging: Identifying and managing errors in asynchronous code makes
it easier to diagnose issues during development
Without proper error handling, your asynchronous code could fail silently, leading to hard-to-
idx_f156f649
promises, using async/await syntax, and applying best practices for robust error handling.
Chapter 6 166
operations. A promise represents a value that may not be available yet but will be resolved in
the future. Every promise starts in a pending state and then settles into either fulfilled
(successful operation) or rejected (operation failed).
There are three main methods you can use to handle the results and errors of a promise:
• then(): Used to handle successful results of a promise
• catch(): Used to handle errors or rejections from a promise
• finally(): Used to execute code after the promise is settled, regardless of the outcome
Let's look at an example of handling errors with promises, to demonstrate the concepts we just
shared:
• Error propagation: If an error occurs during the fetch operation or when processing
the response, it is propagated through the chain and caught in the catch block
• Graceful recovery: The finally block ensures that certain cleanup actions can be
performed, regardless of whether the operation succeeded or failed
167 Error Handling, Debugging, and Security Best Practices
allows developers to write asynchronous functions that look and behave like synchronous
code, making them easier to read and maintain—without sacrificing the benefits of non-
blocking execution.
When working with async/await, there are three key elements that help manage
asynchronous operations and handle potential errors effectively:
• async: A keyword used to declare an asynchronous function, allowing the use of await
within it
• await: A keyword used to pause the execution of an async function until the promise is
resolved
• try-catch: Blocks that wrap await calls to catch and handle errors that may occur
during asynchronous execution
Let's look at an example of handling errors with async/await:
fetchData("[Link]
written in a way that looks synchronous, making it easier to follow and understand
• Error handling with try/catch: Just like in synchronous code, errors can be caught
using a try/catch block, making the error handling process intuitive and
straightforward
• Final actions with finally: The finally block ensures that any necessary cleanup or
final actions are performed after the asynchronous operation is completed
Here are the benefits of using async/await for error handling:
idx_c6fd597e
• Improved readability: The code is easier to read and write, resembling synchronous
code while maintaining asynchronous behavior
• Centralized error handling: Errors are handled in a familiar way using try/catch,
making the code base more consistent
• Less boilerplate: async/await reduces the need for chaining .then() and .catch(),
simplifying the code
While promises and async/await are JavaScript features, TypeScript introduces additional
idx_f11f9284
compile-time safety when working with errors. One important difference is how errors are
typed inside a catch block.
You might wonder why TypeScript does not simply assume that every caught value is an
instance of Error, especially if your project follows good practices and always throws proper
Error objects.
Inside your own code base, you can absolutely enforce a convention like this:
However, JavaScript does not restrict what can be thrown. Any value can be used with throw,
including the following:
Even if your own project standardizes error creation, you cannot guarantee how external
dependencies behave. By treating errors as unknown, TypeScript helps you write defensive,
reliable code at system boundaries—especially in asynchronous operations that involve APIs
or third-party services.
follow to ensure that your asynchronous error handling is robust and reliable:
• Always handle errors: Never leave a promise or async function without error
handling. Use catch() with promises or try/catch with async/await to manage errors.
• Use finally for cleanup: If you need to perform cleanup actions (e.g., closing a
connection or releasing resources), use the finally block to ensure that it happens
regardless of success or failure.
• Graceful degradation: When an error occurs, your application should fail gracefully,
providing useful feedback to the user and preventing the entire application from
crashing.
Chapter 6 170
techniques that make this process easier and more effective. These tools let you inspect
program state, follow the flow of execution, and catch problems before they make it to
production.
Some of the most commonly used debugging tools are the following:
• Editor debuggers: Most modern editors and IDEs include built-in debuggers that
idx_52d583fc idx_37642014
allow you to step through code, inspect variables, evaluate expressions, and observe
execution flow. In this book, we use VS Code for demonstrations, but the same concepts
apply to other editors.
• Browser DevTools: Modern browsers provide powerful developer tools for inspecting idx_539daf40 idx_1e378404
runtime behavior, network activity, performance, and console output. When working
with frontend TypeScript applications, these tools are essential for diagnosing issues in
real time.
• Source maps: Source maps bridge compiled JavaScript and original TypeScript files,
idx_4af6b26f idx_15ad23f9
allowing you to debug your application as if it were running the original TypeScript
source.
• Breakpoints: When running code inside a debugger, breakpoints pause execution at
idx_c1aa50ab idx_dff14c37
specific lines so you can inspect program state and understand how execution reached
that point.
• Console logs: Simple [Link]() statements help trace values and execution flow. idx_45160130
Although basic, they remain one of the fastest ways to investigate issues.
idx_7c8e5ba5
171 Error Handling, Debugging, and Security Best Practices
These tools work best when used together, giving you a complete picture of your application's
behavior. Let's start by looking at source maps.
source maps. Source maps (.[Link] files) link the compiled JavaScript back to your original
TypeScript code, allowing debuggers to display and interact with your TypeScript files directly.
To enable source maps, configure your [Link] file as follows:
1. Enable source maps in [Link]: In your project root, add (or update) a
[Link] file to include the sourceMap option:
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"outDir": "./dist",
"sourceMap": true
}
}
This tells the TypeScript compiler to generate .map files alongside your compiled
JavaScript.
2. Create a simple example file: Let's add a minimal [Link] file:
npx tsc
Chapter 6 172
dist/
[Link]
[Link]
4. Inspect the output: First, check the [Link] file (compiled JavaScript):
function greet(name) {
return `Hello, ${name}!`;
}
const message = greet("Alice");
[Link](message);
//# sourceMappingURL=[Link]
Take note of the final line, //# sourceMappingURL=[Link]. It tells the debugger
that a source map exists. See the following figure for a reference to what the source map
looks like:
Figure 6.10 — Displaying the contents of the generated source map file
5. Debug with source maps: Now that we have our source maps in place, any
breakpoints you add in VS Code will map directly to your TypeScript source instead of
idx_6ea228b6
the compiled JavaScript. This means you can debug in the code you actually wrote,
making the process smoother, clearer, and much more natural.
environment. It provides a visual way to debug your TypeScript code by allowing you to pause
execution, inspect variables, and step through your program.
173 Error Handling, Debugging, and Security Best Practices
We're focusing on the VS Code debugger because this book uses VS Code as the primary editor.
The same debugging concepts (such as setting breakpoints, stepping through code, and
inspecting variables) apply in most modern editors, even if the setup steps differ slightly.
We'll continue working with the same [Link] file we created in the source maps example.
This way, you can see how the debugger builds on top of the setup you already have.
To use the VS Code debugger, we will go over the following steps.
Figure 6.11 — The Run and Debug icon in the VS Code sidebar, used to open the Run and Debug view (Ctrl + Shift +
D/Cmd + Shift + D on macOS)
Figure 6.12 — The create a [Link] file option in VS Code's Run and Debug view, used to configure debugging
settings
Chapter 6 174
Figure 6.13 — Selecting [Link] as the debugging environment in VS Code after clicking create a [Link] file
to configure the debugging settings for a [Link] application
Following the instructions in this step, as shown in the preceding figure, will generate a
[Link] file in your .vscode folder. It contains configurations that tell the debugger how
to launch and manage your program. The file content should look like this:
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: [Link]
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/Chapter6/source-maps/dist/[Link]",
"outFiles": ["${workspaceFolder}/**/*.js"]
}
]
}
• "request": "launch": Indicates that you want to launch the program instead of
attaching to a running process
• "name": "Launch Program": A label for this debug configuration
175 Error Handling, Debugging, and Security Best Practices
we are going to add a breakpoint. A breakpoint tells the debugger where to pause execution:
1. Open your [Link] file.
2. Click in the gutter (the area to the left of the line numbers) on the line where you want
to pause.
3. A red dot appears, marking the breakpoint.
Figure 6.14 — The figure shows a breakpoint added to the [Link](message); line in [Link],
allowing inspection of the greeting variable during debugging
Now that we have our breakpoint in place, let's start debugging in the next step.
idx_fc9ded9c
Figure 6.15 — VS Code debugging interface showcasing variable inspection in the Variables pane, along with
controls for step over (F10), step into (F11), step out (Shift + F11), and continue (F5) during a debugging session
When you hit the play button or F5, execution resumes, and you can see the result in DEBUG
idx_0d10bfd6
VS Code also provides advanced breakpoint options that let you debug more efficiently. If you
idx_97671da2
right-click on the gutter (the space to the left of the line numbers), instead of immediately
adding a regular breakpoint, you'll see a menu of options as in the following screenshot:
Figure 6.17 — Breakpoint menu in VS Code showing Add Breakpoint, Add Conditional Breakpoint, and Add
Logpoint options
• Line breakpoints: These are the most common type of breakpoint, where you click
idx_da0e0ef3
next to a line number in your code to set a breakpoint. The debugger will pause
execution when it reaches that line. We've seen this already.
• Conditional breakpoints: These allow you to pause execution only if a certain
idx_fd636d80
condition is met, such as when a variable reaches a specific value. Here is an example:
count > 10
This ensures that the program only stops when count is greater than 10.
• Logpoints: These are like breakpoints, but instead of pausing execution, they log a
idx_e89d1d85
message to the console without stopping your code. This is useful for quick checks
without interrupting your flow. Here is an example:
These are useful when you want quick feedback without interrupting the program.
Chapter 6 178
By setting a breakpoint on let processedData = [Link](num => num * 2);, you can
idx_8b194b7e
pause the execution and inspect the data array and the processedData array, ensuring that
your transformation logic is working correctly.
logs remain a simple yet effective method for debugging. They allow you to output information
to the console at runtime, helping you track the flow of your application and inspect values at
specific points in your code.
There are two ways of using [Link]:
• Basic usage: You can insert [Link]() statements in your code to print out the
idx_7183127d
values of variables, the results of expressions, or messages indicating that a certain part
of the code has been reached
• Debugging specific issues: You can use [Link]() to verify that your code is
idx_9a29a4ab
reaching the expected lines or to check the values of variables at different stages of your
program
Let's see an example:
return finalPrice;
}
calculateDiscount(100, 20);
In this example, [Link]() statements are used to print out the original price, discount
percentage, and the final price after the discount is applied. This helps you verify that your
discount calculation is correct.
Here are the advanced console methods:
idx_466a7103
• [Link](): Use this to output error messages to the console, typically in red
text, which makes it clear that something went wrong
• [Link](): Use this to output warnings that something might not be working as
expected, but isn't necessarily breaking the code
• [Link](): This method is useful for logging arrays or objects in a tabular
format, making it easier to visualize the data
Here are the best practices with console logs:
idx_f228b46a
handle sensitive data, user input, and interact with external services. Adopting security best
practices helps you build applications that are not only functional but also resilient against
common security threats. This section provides a comprehensive guide to understanding the
importance of security in TypeScript and practical strategies to implement robust security
measures.
In this section, we will cover the following key topics:
• Introduction to security practices
• Input validation and data sanitization
• Secure coding techniques
• Error handling with security in mind
• Managing sensitive data
By the end of this section, you will be able to apply these best practices in your TypeScript
projects, ensuring that your applications are protected against vulnerabilities and security
breaches.
When building secure applications, it's important to keep in mind the three fundamental
security goals:
• Confidentiality: Ensuring that data is only accessible to those authorized to view it
• Integrity: Preventing unauthorized modifications to data
• Availability: Ensuring that your application is available and accessible when needed,
without being compromised by attacks
These principles, often called the CIA triad, form the foundation of every security practice
you'll apply in your TypeScript projects.
In the next section, we will look at input validation and data sanitization.
input to inject malicious scripts (XSS) or manipulate database queries (SQL injection). By
validating and sanitizing input, you ensure that your application only processes safe and
expected data.
Input validation
Input validation is the process of checking user inputs to ensure they meet the expected
idx_db021ddb
the user but is not a substitute for server-side validation, as it can be bypassed
• Server-side validation: This is the primary defense line, as it ensures that data meets
idx_6134d4f6
• Whitelist validation: Only allow specific inputs that are known to be safe, such as
known formats for email addresses or specific allowed characters
• Use regular expressions: Regular expressions can be used to define acceptable input
patterns, ensuring that the data matches the required format
• Limit input length: Restrict input lengths to prevent buffer overflow attacks or overly
large submissions that could crash your application
Chapter 6 182
if (!validateEmail(userInputEmail)) {
throw new Error("Invalid email format.");
}
In this example, an email validation function checks whether the user input conforms to the
expected email pattern. If not, an error is thrown, preventing the invalid data from being
processed further.
Data sanitization
Data sanitization involves cleaning or filtering user inputs to remove potentially harmful
idx_ebcc8440 idx_21cc8d47
• Escape special characters: Convert special characters (<, >, ", and ') into safe
representations to prevent them from being executed as code
• Remove unwanted elements: Strip out potentially dangerous content, such as HTML
tags or SQL keywords, that could be used for injections
Here is an example of data sanitization in TypeScript:
idx_5e7d8d36
• It returns a cleaned version of the input string, helping reduce the risk of executing
harmful code
• The sanitized input is then stored in sanitizedUserInput
In real-world applications, you'd typically use a validation and sanitization library such as Zod
([Link] or Joi ([Link] for a more robust and
maintainable approach—especially when handling complex user input or validating
structured data.
involve designing your code to minimize vulnerabilities and protect against common attack
vectors such as XSS, SQL injection, and data exposure.
This section covers key practices for writing secure TypeScript code, including input validation,
error handling, and sensitive data management.
Here are the key secure coding practices:
• Avoid using eval(): The eval() function executes a string as code, which can be
exploited to run malicious code. Avoid using it or restrict its usage to safe inputs only.
• Use TypeScript's type system: Leverage TypeScript's strong typing to reduce errors
and prevent type-related vulnerabilities. For instance, use specific types instead of any
to avoid unexpected values.
• Implement secure defaults: Set secure defaults for all configuration options, such as
using HTTPS for network communication and requiring strong passwords for
authentication.
Let's see an example of secure coding in TypeScript:
In the preceding example, the function validates the id parameter before doing anything else.
It checks two things:
• The id type must be a number. This prevents unexpected values such as strings,
objects, or malicious payloads from being used.
• The value of id must be greater than zero. This ensures that the input falls within an
acceptable range, rejecting invalid or nonsensical values.
If either check fails, the function immediately returns null instead of processing the input.
This "fail fast" approach stops invalid data from propagating deeper into the system, where it
could cause errors, data corruption, or open the door to injection attacks.
Here are some additional secure coding considerations:
• Handle sensitive data carefully: Avoid logging sensitive information such as
passwords or tokens
• Use parameterized queries: When interacting with databases, always use
parameterized queries to prevent SQL injection
idx_4f263d5a
Now that we understand data sanitization, it's time to explore error handling. In this section,
we'll look at how to handle errors securely.
try {
// Code that might throw an error
} catch (error) {
[Link]("An error occurred. Please try again later."); // User-friendly
message
185 Error Handling, Debugging, and Security Best Practices
data is stored, transmitted, and processed securely helps prevent unauthorized access.
Here are the key practices for managing sensitive data:
• Use encryption: Encrypt sensitive data both at rest and in transit to protect it from
unauthorized access. Encryption converts data into an unreadable format that can only
be accessed with the correct decryption key, making it secure from unauthorized
access :
◦ When storing user passwords, always hash them (e.g., using bcrypt) instead of
saving them as plain text
◦ For data transmission, use HTTPS to ensure that data is encrypted while
traveling between the client and server
• Secure API keys and credentials: Store API keys, tokens, and credentials securely,
using environment variables or secure storage solutions instead of hardcoding them in
your code.
Let's see an example of how you can securely store your keys using environment
variables, in a .env file:
API_KEY=your_secret_key_here
Chapter 6 186
This way, your keys remain secure and aren't exposed in your code base.
• Implement access controls: Restrict access to sensitive data based on roles and
permissions, ensuring that only authorized users can access it.
For example, use role-based access control (RBAC) to define who can access certain
parts of your application. For instance, only admins should have access to user
management functions.
Implement middleware that checks a user's role before allowing access:
In this example, the isAdmin middleware function checks whether the user's role is
idx_a33bb79f
'admin' before allowing access to the next route or controller. Here's how it works:
◦ [Link]: This checks the user's role, which is assumed to be stored in the
[Link] object
Summary
In this chapter, we explored the crucial aspects of error handling, debugging, and security best
practices in TypeScript. We began by understanding various error types—syntax, runtime, and
logical errors—and examined common error patterns and strategies to manage them
effectively. This foundational knowledge equipped us with the skills to identify and address
errors efficiently in any TypeScript application.
We then delved into synchronous and asynchronous error handling, highlighting practical
techniques such as try-catch blocks for synchronous errors and using promises and async/
await for managing asynchronous errors. We emphasized the importance of proper error
handling to create resilient applications that can gracefully recover from unexpected issues.
Next, we covered debugging tools and techniques, focusing on how to effectively use the VS
Code debugger, source maps, breakpoints, and console logs. Mastering these tools enhances
your ability to troubleshoot and resolve issues swiftly, ultimately improving the reliability of
your code.
Finally, we discussed security best practices in TypeScript, highlighting the importance of
input validation, data sanitization, and secure coding techniques to protect applications from
common vulnerabilities. We also covered how to handle sensitive data securely, including the
use of encryption, secure storage of credentials, and implementing access controls.
By mastering these concepts, you are now equipped to handle errors gracefully, debug
effectively, and implement security measures to safeguard your TypeScript applications,
making them more robust, maintainable, and secure.
Looking ahead, in the next chapter, we will shift our focus to performance optimization.
Chapter 6 188
Note: Keep your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
7
Maximizing Performance
Optimization
In this chapter, we will discuss various techniques and strategies to optimize the performance
of TypeScript applications. You will learn techniques for identifying bottlenecks, profiling
tools, and implementing code optimization strategies such as minification, tree shaking, lazy
loading, and caching. The goal is to equip you with actionable strategies that can be applied in
real-world scenarios to enhance the efficiency of the applications. Performance optimization is
crucial not just for improving user experience but also for reducing resource consumption,
which can lead to cost savings.
Here is what you will learn as part of this chapter:
• Understanding the importance of performance optimization
• Identifying performance bottlenecks
• Performance-enhancing techniques
By the end of this chapter, you will have a solid understanding of how to maximize the
performance of TypeScript applications, ensuring that your projects are not only functional but
also efficient.
Technical requirements
You can download the example project and code for this book by following the instructions in
the Download the example code files section in the Preface of this book.
This chapter's code files are included in the downloadable code bundle.
Chapter 7 190
world performance becomes just as important as the features it provides. Optimization isn't
only about speed; it's about keeping your application responsive, efficient, and cost-effective.
In this section, we'll look at why performance optimization matters, how it affects both users
and developers, and the core principles behind building high-performing TypeScript
applications. Understanding these ideas early will help you make smarter architectural
decisions and write code that scales smoothly.
correctly, it might not be optimized to give a great user experience. A slow application can
frustrate users, cause high bounce rates, and even lead to lost revenue.
Performance optimization ensures that your TypeScript applications run fast, respond quickly,
and use fewer system resources. This is especially important as applications grow in
complexity, handle more users, and process large amounts of data.
Here are some key reasons why optimizing performance is crucial:
• Improves user experience: Users expect applications to load quickly and respond
instantly. Research supports this strongly. For example, Google's data shows that
increasing page load time from 1 second to 3 seconds raises the likelihood of users
bouncing by 32% ([Link]
your-bottom-line). In an e-commerce context, studies show that even 100 ms of
additional latency can reduce conversion rates by 2–7%, depending on the application
([Link]
319449830_The_Impact_of_Web_Pages'_Load_Time_on_the_Conversion_Rate_of_an_E-
Commerce_Platform).
Imagine opening an online store and waiting 10 seconds for a product page to load.
Most users would close the tab and look elsewhere. Performance optimization helps
prevent this by making sure your application responds as quickly as possible.
• Reduces resource consumption: Optimized applications use less CPU, memory, and
network bandwidth. This is important for the following reasons:
◦ It improves battery life for mobile users
191 Maximizing Performance Optimization
• Memory leaks: Not properly cleaning up event listeners, variables, or references can
cause the app to use more and more memory over time
Chapter 7 192
two approaches to summing numbers in an array. Here is the traditional loop example:
return total;
}
The for loop performs a straightforward iteration. It directly accesses array elements and
updates the accumulator. In many JavaScript engines, this approach is highly optimized and
can be faster than higher-order methods for large datasets. Here is an example using the
reduce() function:
[Link]("Reduce Sum");
[Link](sumWithReduce(nums));
[Link]("Reduce Sum");
The reduce() method provides a more declarative and expressive way to perform the same
idx_4d3389ee
operation. Instead of manually managing an index and an accumulator, the logic is expressed
in a functional style.
193 Maximizing Performance Optimization
• Use a for loop when performance is critical or when fine-grained control is required
idx_16c21ec4
with code examples, tool configurations, and setups in the upcoming section, Performance-
enhancing techniques. For now, here's what you need to know at a high level:
• Code splitting: Instead of requiring users to download the entire application bundle
just to load something simple, such as the home page, code splitting breaks the app
into smaller, logical chunks. These chunks are loaded only when needed, such as when
a user navigates to a specific route, interacts with a component, or triggers an on-
demand feature. This approach dramatically reduces initial load time and improves the
overall user experience.
• Minification: By removing unnecessary characters from your code (such as white
spaces and comments), you can reduce file sizes, resulting in faster downloads.
• Tree shaking: This process involves removing unused code from your application
during the build process. This helps in reducing the bundle size, making the application
faster.
• Caching: Caching focuses on avoiding repeated work. Instead of recalculating values,
idx_f9cf752a
scalable as they grow. In the next section, we'll move from understanding the problems to
actively finding and fixing them. You'll learn how to use powerful tools such as Chrome
DevTools, Webpack Bundle Analyzer, and [Link] performance hooks to pinpoint bottlenecks,
analyze slow functions, API calls, and memory usage, and apply targeted fixes so your
application runs efficiently and scales smoothly.
Identifying these bottlenecks is crucial for optimizing your TypeScript applications effectively.
In this section, we will explore how to pinpoint these issues using profiling tools, recognize
common performance problems, and prioritize optimization efforts.
Bottlenecks can happen in many areas, including the following:
• Slow functions that take too much time to execute
• Unnecessary re-renders in frameworks such as React
• Memory leaks that cause the app to use more RAM over time
• Large JavaScript bundles that slow down downloading times
• Inefficient API calls that block or slow down other processes
To fix performance issues, we first need to measure performance, detect slow areas, and
prioritize what to optimize.
into how much time each function takes, how memory is being used, and which parts of the
idx_c103c833
app are slowing things down. In this section, we will take a look at Chrome DevTools
Performance Profiler and see how to use it to profile your application.
regarded as one of the best (and most accessible) tools for performance analysis. Here's why it
idx_0fbc357f
stands out:
• It's built directly into Chrome, no extra installation required
195 Maximizing Performance Optimization
<!DOCTYPE html>
<html>
<head>
<title>DevTools Performance Demo</title>
<style>body { font-family: sans-serif; padding: 2rem; } button { padding: 1rem
2rem; font-size: 1.2rem; margin: 0.5rem; }</style>
</head>
<body>
<h1>Chrome DevTools Performance Demo</h1>
<button id="slow">Run Very Slow Code (~5 sec block)</button>
<button id="fast">Run Fast Code</button>
<script>
function wasteCpuTime() {
const start = [Link]();
while ([Link]() - start < 5000) { /* spin */ }
[Link]("Done wasting 5 seconds");
}
[Link]("slow").onclick = wasteCpuTime;
[Link]("fast").onclick = () => [Link]("Instant!");
</script>
</body>
</html>
Chapter 7 196
5. Once the freeze is over and the button is responsive again, click the Stop button, which
replaced the Record button.
Your Performance tab should instantly populate with a colorful timeline, looking very similar
to the following figure. This timeline is the visual proof of the long task you just created.
Figure 7.1 – Chrome DevTools Performance recording showing a long main-thread task (~5 seconds) triggered by
the slow implementation, illustrating how blocking code impacts responsiveness and interaction latency (INP)
197 Maximizing Performance Optimization
• Key metrics (top left): The first thing to notice is the Interaction to Next Paint (INP)
idx_22540cd3
value: 5099 ms. This is extremely poor. Anything above 200 ms is considered a bad
user experience. INP measures how long the page takes to visually respond to user
input, so a 5-second delay means the UI was completely unresponsive.
Largest contentful paint (LCP) and cummulative layout shift (CLS) both show 0,
simply because those metrics weren't triggered during this recording (no major content
loads or layout shifts occurred).
• The timeline (main area): In the main timeline, the most obvious issue is the red bar
spanning ~5,099 ms, which signals a long task. Directly beneath it is a yellow bar
indicating continuous JavaScript execution on the main thread. This yellow span from
0–5,000 ms tells us the browser was blocked the entire time.
Expanding the Main section reveals the full breakdown as we see in the preceding
figure:
◦ A red Task bar showing Long task took 5.00 s
◦ A yellow Event: click frame
◦ A yellow Function call frame
◦ And finally, a long purple bar representing the offending function:
wasteCpuTime
This view shows precisely where execution time went and which function caused the lock-up.
In the Summary section underneath, Scripting is roughly equal to 5,000 ms, confirming that
the slowdown was entirely due to JavaScript, not rendering, not layout, and not painting. The
browser simply couldn't do anything else because the main thread was monopolized by one
idx_c7a5dd79
long-running function.
This recording clearly pinpoints the problem: DevTools shows exactly which function
(wasteCpuTime) blocked the main thread, how long the UI remained frozen, and where the
stall occurred in the call stack. It also highlights that the delay was caused solely by JavaScript
execution. When you see long purple bars stacked under red markers, DevTools is essentially
telling you: This code is blocking the main thread.
Run the recording again using the fast code version, and you'll see the opposite behavior: no
red bars, minimal scripting time, and a responsive main thread.
Now that we have an idea of how the Chrome DevTools Performance Profiler works, let's move
on to the Webpack Bundle Analyzer in the next subsection.
Chapter 7 198
large. The Webpack Bundle Analyzer helps you see what's taking up the most space.
Let's see how to use the Webpack Bundle Analyzer with the following demo. We will set up a
idx_3bbc3989 idx_a8d4e299
simple React project with a huge bundle dominated by moment and lodash, then see how you
would go about shrinking the bundle:
1. Create the project:
2. Create [Link]:
{
"compilerOptions": {
"target": "es2020",
"module": "esnext",
"moduleResolution": "node",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"lib": ["dom", "[Link]", "es6"]
},
"include": ["src"]
}
3. Create [Link]:
[Link] = {
mode: 'production',
199 Maximizing Performance Optimization
entry: './src/[Link]',
module: {
rules: [{ test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ }]
},
resolve: { extensions: ['.tsx', '.ts', '.js'] },
plugins: [
new BundleAnalyzerPlugin() // ← this line opens the visualizer
automatically
]
};
[Link]('moment&lodashareHUGE:',moment,_);
[Link](root).render(
<div style={{ padding: '3rem', fontFamily: 'system-ui', lineHeight:
1.6 }}>
<h1>Webpack Bundle Analyzer Demo </h1>
<p>Open <strong>[Link] to see the treemap!</p>
<p>You'll see <code>moment</code> and <code>lodash</
code> eating almost the entire bundle.</p>
Chapter 7 200
</div>
);
npx webpack
Your browser automatically opens the interactive treemap at [Link] See the
idx_04925176
Figure 7.2 – Webpack Bundle Analyzer – the before picture (real output from our demo)
• The biggest green rectangle on the left is moment; it alone takes up roughly ⅓ of the
idx_5e9aa4bf
This visualization is the power of the analyzer: it uses a single screen to tell you precisely which
libraries need replacement or optimization. While we dedicate the Performance-enhancing
techniques section to the detailed solutions, such as replacing heavy libraries (e.g., swapping
moment for dayjs), excluding unnecessary assets (e.g., removing moment locale files), and
implementing tree-shaking (importing only necessary functions from libraries such as
lodash), the treemap already confirms the fundamental cause: 100% of the bundle bloat is
driven by external dependencies, not your own code.
function slowFunction() {
let total = 0;
for (let i = 0; i < 1e7; i++) {
total += i;
}
return total;
}
This script will show how many milliseconds the function takes to run. If it's too slow, you can
look for ways to optimize it.
slow function is one that takes too long to execute or performs unnecessary calculations.
idx_f3943ce7
In this implementation, the array is sorted on every invocation of processData. If this function
is called frequently, such as during rendering, user interactions, or data updates, the repeated
sorting becomes a performance bottleneck.
A more efficient approach is to sort the data once and reuse the result:
processDataOptimized(sortedData);
203 Maximizing Performance Optimization
By moving the sorting operation outside the hot path, this approach avoids unnecessary
repeated work. The optimized version reduces CPU usage, improves execution time, and scales
better as the size of the dataset or the number of function calls increases.
This example highlights an important performance principle: removing redundant
computation often delivers far greater gains than micro-optimizing individual operations or
APIs.
excessive re-renders:
1. Use React Developer Tools in Chrome.
2. Enable Highlight updates when components render in the React Profiler.
3. Check whether components re-render too frequently when they shouldn't.
Here is an example of preventing unnecessary re-renders. This is bad practice (causes re-
renders on every state update):
useEffect(() => {
[Link]("resize", () => [Link]("Resized!"));
}, []);
Here is the optimized version (removes the listener when the component unmounts):
useEffect(() => {
const handleResize = () => [Link]("Resized!");
[Link]("resize", handleResize);
return () => {
[Link]("resize", handleResize);
};
}, []);
By removing event listeners when they're no longer needed, we free up memory and prevent
idx_cc67b2a2
leaks. The following figure demonstrates how memory usage increases over time when
cleanup is not properly handled.
205 Maximizing Performance Optimization
Figure 7.3 – Parent state update triggering unnecessary child re-renders, resulting in wasted CPU work in
component-based frameworks
The preceding figure illustrates how state updates in component-based frameworks such as
React can lead to wasted CPU work. When a state update occurs in a parent component, it
triggers a re-render. By default, this re-render cascades down the component tree, forcing all
child components to re-render as well. The problem is that many of these components, such as
Child 2 and Child 3 in the example, may not have received any new data, yet they still go
through the full re-render process.
This unnecessary work shows up as the Wasted Work portion of the CPU usage chart,
idx_e690ea21
representing time spent re-rendering components that produce no visible changes in the UI.
The key takeaway is that optimization techniques such as [Link]() can break this
automatic cascade. By preventing components from re-rendering when their inputs haven't
changed, you can significantly reduce wasted CPU cycles and improve rendering efficiency.
In the next section, let's look at strategies we can use to optimize our applications.
Chapter 7 206
• Start with user experience: Fix the slowest and most frustrating issues first
• Look for quick wins: Optimize things that require little effort but bring big
improvements
• Focus on high-impact areas: Optimize functions that run frequently or affect multiple
parts of the app
• Reduce bundle size: Use tree shaking and lazy loading to remove unnecessary code
• Monitor and improve over time: Regularly profile your app and make adjustments
In this section, you learned how to measure and analyze performance using tools such as
Chrome DevTools, the Webpack Bundle Analyzer, and [Link] performance hooks. These tools
help you detect issues such as slow functions, excessive re-renders, and memory leaks, giving
you visibility into what's really happening under the hood.
You also learned how to apply practical code optimizations and prioritize performance
improvements based on user impact and efficiency. With a clearer picture of where your
application is slowing down, the next topic will focus on strategies for enhancing performance
in a more structured and effective way.
Performance-enhancing techniques
Optimizing the performance of your TypeScript applications involves various techniques that
idx_2deecead
help improve loading times, responsiveness, and overall efficiency. In this section, we will
explore several effective performance-enhancing techniques, including lazy loading, code
splitting, tree shaking, caching mechanisms, and optimization of loops and asynchronous
operations.
Some key methods include the following:
idx_57adfb70
• Lazy loading and code splitting: Load only what's needed when it's needed
• Tree shaking: Remove unused code to reduce bundle size
• Caching mechanisms: Store data efficiently to reduce redundant operations
• Optimizing loops, recursive functions, and asynchronous operations: Write better-
performing code
207 Maximizing Performance Optimization
performance by reducing the amount of JavaScript loaded during the initial render:
• Lazy loading delays loading specific components until they are needed
• Code splitting breaks large bundles into smaller chunks that can be loaded
independently
Together, these techniques reduce initial bundle size and improve startup performance.
it. This improves page speed and reduces initial load time.
React provides a built-in lazy() function to dynamically import components only when they
are needed.
This example shows the code before lazy loading (eager loading):
function App() {
return (
<div>
<Dashboard />
<Settings />
</div>
);
}
In this example, both Dashboard and Settings are bundled and loaded immediately, even if
idx_9af03a10 idx_7e2f8487
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
<Suspense fallback={<div>Loading...</div>}>
<Settings />
</Suspense>
</div>
);
}
Here, Dashboard and Settings are loaded only when rendered. This reduces the initial
JavaScript bundle size and improves startup performance.
Lazy loading works by leveraging dynamic import() statements, which signal the bundler to
create separate chunks.
that users download only the code required for the current view, instead of loading the entire
application upfront.
Without code splitting, bundlers typically generate a single large bundle containing all features
and dependencies. As applications grow, this increases the following:
• Download time
• Parse and execution time
• Main thread blocking during startup
See the following examples:
• Before code splitting:
[Link] → 3.5 MB
[Link] → 800 KB
[Link] → 600 KB
209 Maximizing Performance Optimization
[Link] → 400 KB
[Link] → 700 KB
Only the entry bundle loads initially. Additional chunks are fetched on demand (e.g.,
when navigating to a route).
This improves the following: idx_9d3dde9e
[Link] = {
optimization: {
splitChunks: {
chunks: "all",
},
},
};
This configuration enables automatic chunk splitting and shared dependency extraction.
Note
Key takeaway: Code splitting does not make individual functions faster. It improves
startup performance by reducing upfront work and deferring non-critical code.
ensures that only the required functions and modules are included.
For example, if a library contains 10 functions but you only use 2, tree shaking removes the
unused 8 functions from the final bundle.
Let's see an example of unoptimized code without tree shaking:
// [Link]
export function add(a: number, b: number) {
return a + b;
Chapter 7 210
// [Link]
import { add } from "./utils";
[Link](add(2, 3));
Even though we only use add(), multiply() is still included in the final bundle.
{
"sideEffects": false
}
This removes any unused functions, reducing your final file size.
• Browser caching: Store static files so they don't reload every time idx_65b3ac72
• API response caching: Save API results to prevent unnecessary requests idx_4f929e78
if (key in cache) {
return cache[key];
} const result = fn(arg);
cache[key] = result;
return result;
};
}
Optimizing loops
A common misconception is that higher-level array methods, such as reduce(), are always
idx_f088f5b4 idx_0bcd98a2
faster than a manual loop. In practice, performance depends on factors such as the JavaScript
engine, dataset size, and callback overhead.
In many performance-critical paths, a traditional loop offers predictable execution and
minimal overhead, while methods such as reduce() trade a small amount of performance for
cleaner and more expressive code.
Chapter 7 212
This example uses a for loop (often fastest and most predictable):
• Use a for loop when performance is critical, memory usage must be predictable, or the
logic runs frequently in hot paths
• Use reduce() when readability, maintainability, and declarative style are more
important than small performance differences
Optimizing recursive functions
Recursive solutions can be elegant and expressive, but they may introduce performance and
idx_b2745235 idx_ee2cc4c7
stability issues when inputs grow large. Each recursive call consumes stack memory, which can
lead to stack overflows if the recursion depth becomes excessive:
Let's look at an optimized version using tail recursion. A function is tail-recursive when the
idx_b0bb9553
recursive call is the final operation performed, leaving no additional work after the call returns.
This is typically achieved by carrying intermediate results forward using an accumulator:
Note
Although tail recursion is a useful conceptual optimization, JavaScript and TypeScript idx_c423895f
recursive functions may still consume stack frames and can overflow for large inputs.
Because of this, tail recursion should be viewed as a code clarity technique, not a
reliable memory optimization in JavaScript.
For large inputs or performance-critical scenarios, an iterative approach is often the safest and
idx_fa7358e7 idx_edac74be
and risk runtime failures under heavy load. Iterative loops use constant stack space, making
them safer in environments where memory usage must remain predictable.
In time-sensitive systems—such as real-time monitoring, live data processing, or frequent UI
updates—predictable execution paths matter more than abstraction style. Iterative
approaches are generally easier to reason about in terms of worst-case execution time and
memory behavior.
As a practical rule of thumb, follow these guidelines: idx_d4cd0b97
• Prefer loops when memory usage, stability, or predictable execution time is critical idx_f3a82ac6
• Use recursion when it improves clarity, and the recursion depth is small and well-
idx_59994ab8
bounded
Chapter 7 214
poor coordination can still degrade performance and increase resource usage.
idx_4cda1556
By introducing a delay, we prevent repeated API calls during rapid user input. This reduces
idx_b04f574e idx_c208daaa
Beyond debouncing
Debouncing is just one example of asynchronous optimization. In larger applications, you may
idx_7ea4ffaa idx_6884ffc9
Summary
In this chapter, you learned various performance-enhancing techniques for your TypeScript
applications. We covered lazy loading and code splitting to reduce initial load times, tree
shaking to eliminate unused code, and caching mechanisms to speed up data retrieval. Finally,
we discussed optimizing loops, recursive functions, and asynchronous operations to improve
overall performance.
Throughout this chapter, you've gained a comprehensive understanding of how to enhance the
performance of TypeScript applications. You started by recognizing the importance of
performance optimization for user satisfaction, business success, and cost savings. Then, you
learned how to identify performance bottlenecks using profiling tools and techniques. Finally,
you explored and implemented performance-enhancing techniques such as lazy loading, code
splitting, tree shaking, caching, and optimization of loops and asynchronous operations.
By applying these strategies, you can create TypeScript applications that are fast, responsive,
and scalable, providing an optimal user experience and efficient resource utilization. Keep
monitoring and profiling your application to address new performance challenges and sustain
the effectiveness of your optimizations.
In the next chapter, we will look at mastering design patterns in TypeScript.
Chapter 7 216
Note: Keep your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
8
Mastering Design Patterns in
TypeScript
In this chapter, we will explore design patterns and how they can be effectively implemented
using TypeScript. Design patterns are standard solutions to common problems in software
design. They help developers create code that is more flexible, reusable, and easier to maintain.
By mastering these patterns, you will improve your ability to solve design issues and enhance
the quality of your code.
Understanding design patterns involves knowing their purpose, structure, and when to use
them. This chapter will break down various design patterns into three main categories:
creational, structural, and behavioral. Each category addresses different aspects of software
design, making it easier for you to apply the right pattern for the right situation.
We will provide clear explanations of each pattern, practical examples in TypeScript, and
discussions on the advantages and disadvantages of each approach. We will also cover
essential best practices and tips to help you avoid common pitfalls. This way, you can make
informed decisions when applying these patterns in your projects.
In this chapter, we will cover the following main topics:
• Introducing design patterns
• Creational patterns: Techniques for object creation
• Structural patterns: How to compose classes and objects
• Behavioral patterns: Patterns that define how objects interact
• Practical examples
• Advantages and disadvantages
• Best practices: Tips for implementing design patterns effectively
Chapter 8 218
By the end of this chapter, you will have a solid understanding of how to use design patterns in
TypeScript to create better software solutions.
Technical requirements
You can download the example project and code for this book by following the instructions in
the Download the example code files section in the Preface of this book.
This chapter's code files are included in the downloadable code bundle.
designing applications. Think of a design pattern as a blueprint that helps you solve a recurring
issue in your code. They are not specific pieces of code that you can copy and paste; instead,
they provide a general approach to solving problems in software design.
The idea of design patterns started in other fields, such as architecture, where they were used
to solve common building problems. In software development, the concept was popularized by
a group of authors known as the Gang of Four (GoF). They wrote a famous book in 1994 called
idx_a8ad743e
Next, we'll start with creational patterns, the first major category of design patterns, because
object creation is one of the most common sources of rigidity and duplication in code. We'll
explore patterns such as Factory, Abstract Factory, Builder, and Singleton, and see how they let
you create objects flexibly, control instantiation, and keep your code clean and adaptable from
the very beginning.
object is simple, but in other cases, it can get complicated, especially if the object has many
parts or needs to follow specific rules. Creational patterns are techniques that help you create
objects in a smart, organized way. They make sure that your code stays clean, flexible, and easy
to maintain, even as your project grows.
In this section, we'll discuss four popular creational patterns: Factory, Abstract Factory,
idx_11d9e1a0
Builder, and Singleton. Each pattern solves specific problems in object creation and can make
your life as a developer much easier.
Instead of using the new operator directly (e.g., new PayPalProcessor()), which tightly
couples your code to a specific implementation, you delegate creation to a factory method.
This method decides which class to instantiate based on input logic. This abstraction allows
you to swap or add new payment methods later (such as adding Apple Pay) without rewriting
your application logic.
Let's see a real-world example using payment processors:
process(amount: number) {
[Link](`Processing $${amount} via Stripe Credit Card.`);
}
}
// Usage: The client code asks for a processor, unaware of the specific class
logic
const processor = [Link]("paypal");
[Link](50); // Output: Processing $50 via PayPal.
In this example, the PaymentProcessor interface establishes a contract that both PayPal and
idx_bce39ae5
coupling to concrete classes by centralizing object creation, though adding a new provider
typically requires updating the factory's selection logic in one place.
In contrast, the GoF Factory method pattern delegates object creation to subclasses, and when
applications need families of related objects, the Abstract Factory pattern becomes a natural
extension.
So far, we've focused on choosing which single object to create. But what if your application
needs coordinated objects that come as a matching set? That's where the Abstract Factory
pattern comes in.
221 Mastering Design Patterns in TypeScript
components for a light theme or all for a dark theme) while keeping your client code
idx_1c23b2b0
interface Button {
render(): void;
}
interface Checkbox {
toggle(): void;
}
conceptually into two families, but they all adhere to the interfaces defined in Step 1:
interface ThemeFactory {
createButton(): Button;
createCheckbox(): Checkbox;
}
is where the guarantee happens: LightThemeFactory will never accidentally produce a Dark
button:
// Choose the theme once (could come from user settings, config, etc.)
const themeFactory: ThemeFactory = new DarkThemeFactory();
Switching to Light mode tomorrow? Just change one line (new LightThemeFactory()) and
every component automatically belongs to the new family—no other code changes required.
That's the power of Abstract Factory: it enforces consistency across related objects while
keeping your main application logic clean and theme-agnostic.
In the next section, we'll explore the Builder pattern, perfect for creating complex objects step
by step without bloated constructors.
configurations.
Imagine trying to create a Car object using a standard constructor. If the car has 10 distinct
options (engine, color, GPS, sunroof, etc.), your constructor becomes a messy list of arguments:
new Car("V8", 4, "Red", true, false, null...). This is hard to read and prone to errors.
The Builder pattern solves this by separating the construction of the object from its
idx_5246f163
class Car {
engine!: string;
wheels!: number;
color!: string;
// Imagine many more properties here (sunroof, GPS, etc.)
}
instance of the object we are building (we start with the constructor):
class CarBuilder {
private car: Car;
constructor() {
[Link] = new Car();
}
feature of the Builder pattern. Each method sets one specific part of the car, but crucially, it
returns this (the builder instance itself). Returning this allows us to "chain" methods
together in a single, readable line (e.g., .setEngine().setColor()):
return this;
}
configuration object or a long list of constructor arguments, you build the object step by step,
explicitly stating what you want to configure and when. This makes the construction process
easier to understand, validate, and evolve as new options are added:
[Link](car);
// Output: { engine: 'V8', wheels: 4, color: 'Red' }
By using the Builder pattern, we transformed a potentially complex creation process into a
clean, easy-to-read sentence.
modern TypeScript provides several language features that reduce the need for traditional
builders in many everyday scenarios.
TypeScript supports optional properties, partial types, object literals, and object spread syntax.
Combined with strong type inference and editor autocomplete, these features often allow
developers to construct complex objects clearly and concisely without introducing a dedicated
builder class. This approach is especially common in modern frontend applications, such as
those built with React, where data is frequently assembled incrementally.
Chapter 8 226
For example, instead of using a builder, an object can often be composed like this:
const request = {
...defaultConfig,
...userInput,
metadata: { orderId }
};
This style is flexible, readable, and fits naturally with how data flows through many TypeScript
codebases.
That said, the Builder pattern remains valuable when object construction must follow a strict
idx_348ce0e7
sequence, when validation is required at each step, or when the creation logic itself is complex
and shared across multiple contexts. Builders are also commonly used in SDKs, configuration-
heavy systems, and fluent APIs where readability and controlled construction are important.
Understanding the Builder pattern helps you recognize when it is the right tool—and when
simpler TypeScript constructs are sufficient.
Next, we will look at the Singleton pattern, which handles the exact opposite problem:
ensuring that an object is created only once.
lifecycle of your application and provides a single global point of access to it.
What does global instance mean? Imagine a printer in an office. You don't buy a new printer
every time someone wants to print a document. Instead, everyone connects to the same
existing printer. In software, this is useful for managing shared resources such as database
idx_1f9bbd08 idx_19302552
connections, logging services, or configuration settings, where creating multiple copies idx_c56614e1
instance" rule.
class DatabaseConnection {
// This static property holds the ONE unique instance
private static instance: DatabaseConnection;
227 Mastering Design Patterns in TypeScript
How does it work? When getInstance() is called for db1, the internal instance property is
undefined, triggering the code to execute a new DatabaseConnection() and printing the
"Initializing Database Connection..." message. However, during the second call for db2,
the method detects that an instance already exists, skips the creation step, and simply returns
the original object. As a result, the initialization log prints only once, proving that the entire
application is efficiently sharing a single resource without wasting memory on duplicate idx_51151db5
connections.
Now that we have explored how creational patterns (Factory, Abstract Factory, Builder, and
Singleton) optimize object instantiation, we are ready to focus on how these objects fit
together.
In the next section, we will examine structural patterns, which focus on organizing
relationships between classes and objects. You will learn about the Adapter, Composite,
Decorator, and Facade patterns, and discover how they help you assemble your objects into
larger, more maintainable structures.
help you build code that is easier to manage by simplifying the structure or making different
parts of the code work better together. These patterns solve problems related to composition—
how classes and objects are connected and interact with one another.
Here, we'll discuss four commonly used structural patterns: Adapter, Composite, Decorator,
and Facade. Each of these patterns is designed to solve specific issues in software design by
improving how code components work together.
travel power adapter: you have a US plug (your code), but the wall socket is European (the
idx_5af91a99
makePayment(amount) method:
problem: its method is called processPayment, not makePayment. Our app doesn't know how
to call it:
This class implements the old interface (so our app trusts it) but wraps an instance of the new
system. Inside, it translates the call:
constructor(newPaymentSystem: NewPaymentSystem) {
[Link] = newPaymentSystem;
}
makePayment just like it always has, completely unaware that the adapter is translating the
request in the background:
// 3. Use it! (The code thinks it's using the old system)
[Link](100);
// Output: Payment of $100 processed using modern system
Having learned how to bridge incompatible interfaces with the Adapter pattern, let's now look
at how to manage complex hierarchies of objects using the Composite pattern.
It's like a folder on your computer: you can perform the same actions (such as copy or delete)
on a single file or an entire folder containing files and subfolders.
For example, let's say you have graphic elements such as circles and rectangles, and you want
to group them into a complex drawing. Using the Composite pattern, you can treat individual
shapes and groups of shapes identically.
231 Mastering Design Patterns in TypeScript
shapes and complex groups "look" the same to the rest of the application:
interface Shape {
draw(): void;
}
nodes. They do the actual work (in this case, drawing to the console):
idx_ad712098
addShape(shape: Shape) {
[Link](shape);
}
Shape or calling draw(), the code remains simple. It doesn't need to check whether the variable
group is a single item or a list—it just calls the method:
/* Output:
--- Starting Group Draw ---
Drawing a Circle
Drawing a Rectangle
--- Finished Group Draw ---
*/
The key takeaway is that the client code doesn't care whether it's dealing with a simple circle
idx_a5dac0f2
or a complex ShapeGroup containing a thousand shapes. It treats them exactly the same.
While the Composite pattern focuses on structuring hierarchies of objects, the Decorator
pattern focuses on dynamically extending the functionality of individual objects.
changing its underlying structure. Think of it like adding extra toppings to a pizza: you aren't
baking a brand-new pizza from scratch; you are simply "decorating" the existing one with
cheese or pepperoni.
233 Mastering Design Patterns in TypeScript
In code, this is achieved by wrapping the original object inside a "decorator" class. Let's look
at a coffee shop example where we dynamically add ingredients such as milk and sugar to a
basic brew.
getDescription(): string {
return "Basic Coffee";
}
}
coffee) but also holds a reference to another Coffee object inside it.
When you call getCost() on the decorator, it calls getCost() on the inner coffee first, adds its
own price, and returns the total:
constructor(coffee: Coffee) {
[Link] = coffee;
}
getCost(): number {
return [Link]() + 2; // Add cost of milk
Chapter 8 234
getDescription(): string {
return [Link]() + ", Milk";
}
}
[Link]([Link]());
// Output: Basic Coffee, Milk, Sugar
[Link]([Link]());
// Output: 8 (5 + 2 + 1)
Notice how SugarDecorator wraps MilkDecorator, which wraps BasicCoffee. This "Russian
nesting doll" structure allows you to add infinite features dynamically at runtime.
idx_0b1d6069
While the Decorator pattern adds functionality by layering objects, the Facade pattern does the
opposite: it hides complexity behind a simple interface.
control for a TV: you press a single Power button to turn it on, without needing to know the
technical details of how the power supply, screen, and receiver circuits inside the TV
coordinate to wake up.
In software, this is used when you have a complex system with many moving parts, and you
want to provide a clean, easy-to-use "front door" for the rest of your application.
235 Mastering Design Patterns in TypeScript
Let's look at a home theater example. To watch a movie, you normally have to manually
idx_c7c5e0be
class Amplifier {
on() { [Link]("Amplifier is on"); }
}
class Projector {
on() { [Link]("Projector is on"); }
}
class Lights {
dim() { [Link]("Lights are dimmed"); }
}
HomeTheaterFacade class. This class knows exactly which buttons to push and in what order.
It bundles the complexity of dimming lights, starting the projector, and turning on the amp
into a single method called watchMovie():
class HomeTheaterFacade {
private amp: Amplifier;
private projector: Projector;
private lights: Lights;
[Link]();
[Link]();
[Link]("--- Ready to watch! ---");
}
}
/* Output:
--- Get Ready for the Movie ---
Lights are dimmed
Projector is on
Amplifier is on
--- Ready to watch! ---
*/
The key takeaway is that the Facade doesn't reduce the complexity of the underlying system
(the projector and amp are still there), but it reduces the complexity of using that system.
In this section, we learned how structural patterns allow us to assemble classes and objects
into larger, more flexible structures—a crucial skill for maintaining clean code as applications
grow. In the next section, we will shift our focus to behavioral patterns, where we will explore
how to manage effective communication and responsibility assignment between these objects.
237 Mastering Design Patterns in TypeScript
responsibilities. They are useful for managing communication, coordination, and relationships
between objects, making your code more organized and adaptable.
Here, we'll discuss four key behavioral patterns: Observer, Strategy, Command, and Iterator.
Each pattern solves a specific problem related to object behavior or interaction, allowing you to
write flexible and maintainable code.
to automatically notify different devices, such as a simple display and a sophisticated alert
system.
have an update method. This allows the station to treat all listeners exactly the same way:
interface Observer {
update(temperature: number): void;
}
Chapter 8 238
• Part A: Managing subscriptions: First, we set up the class to hold our data
(temperature) and the list of subscribers. We also need methods to allow observers to
sign up or leave:
class WeatherStation {
// A list to hold everyone listening to this station
private observers: Observer[] = [];
private temperature: number = 0;
• Part B: Triggering notifications: Now, inside the same class, we add the logic to
change the temperature. Crucially, whenever the temperature changes, we
idx_dcd0b836
immediately call notifyObservers() to loop through our list and alert everyone:
private notifyObservers() {
239 Mastering Design Patterns in TypeScript
shows the data, while AlertSystem runs logic to check for danger. Both implement Observer,
so the WeatherStation accepts them both:
[Link](35);
// Output: Display updates AND Alert triggers!
WeatherStation doesn't know what AlertSystem does. It just knows to call .update(). This
allows you to add new types of listeners (such as Logger or FanController) without ever
changing the WeatherStation code.
While Observer handles communication between objects, the Strategy pattern handles
choosing the right behavior for a specific task.
It's like choosing a travel route: you might go by car, bus, or bike, depending on traffic or
weather. The destination is the same, but the strategy for getting there changes.
For example, a payment system might need to support PayPal, credit cards, or bank transfers.
idx_7aa3311b
Instead of writing one giant if-else block, we encapsulate each payment method as its own
strategy.
interface PaymentStrategy {
pay(amount: number): void;
}
know (or care) which specific one it is using. Crucially, it has a setStrategy method, allowing
us to swap behavior dynamically:
class PaymentContext {
// The context holds a reference to the INTERFACE, not a specific class
private strategy!: PaymentStrategy; // Using '!' to assert it will be set
setStrategy(strategy: PaymentStrategy) {
[Link] = strategy;
}
executePayment(amount: number) {
if (![Link]) {
[Link]("No payment method selected!");
return;
}
[Link](amount);
}
}
change our mind, and process the next one with a credit card using the exact same Context
object:
[Link](new PayPalPayment());
[Link](100);
// Output: Paid $100 using PayPal.
The Strategy pattern eliminates complex if/else or switch statements. If you need to add
Bitcoin payments later, you simply create a new BitcoinStrategy class without touching the
PaymentContext code. Now, let's take a look at the Command pattern.
standalone object. This allows you to parameterize objects with operations, queue requests, or
log them. It is the secret sauce behind features such as Undo/Redo because you can store the
command history and reverse it later.
Let's build a music player control system. We want to separate the buttons (the invoker) from
the actual music hardware (the receiver).
class MusicPlayer {
play(track: string) {
[Link](`Now playing: ${track}`);
}
stop() {
[Link]("Music stopped.");
}
}
243 Mastering Design Patterns in TypeScript
interface Command {
execute(): void;
}
(MusicPlayer) to a specific action (.play()). Notice that we can even pass data (such as the
track name) into the command's constructor:
execute() {
[Link]([Link]);
}
}
constructor(player: MusicPlayer) {
[Link] = player;
}
execute() {
[Link]();
}
}
Chapter 8 244
doesn't know it's playing music. It just knows it has a command to execute:
class SmartController {
private command!: Command;
setCommand(command: Command) {
[Link] = command;
}
pressButton() {
[Link]("Button pressed...");
[Link]();
}
}
// 2. Create commands
const playJazz = new PlayMusicCommand(player, "Smooth Jazz");
const stopMusic = new StopMusicCommand(player);
array, list, or tree) one by one, without exposing the collection's underlying memory structure.
Think of it like reading a book. You read one page at a time and flip to the next. You don't need
to know how the binding glue works or how the pages were stitched together; you just need a
standard way to move forward.
the next item and one to check whether there are any items left. We use a generic <T> type so
this interface can work for numbers, strings, or complex objects:
interface Iterator<T> {
next(): T | null;
hasNext(): boolean;
}
constructor(collection: number[]) {
[Link] = collection;
}
hasNext(): boolean {
return [Link] < [Link];
}
}
createIterator(). This allows the collection to pass its data to the iterator without forcing
the user to access the private numbers array directly:
class NumberCollection {
private numbers: number[] = [];
addNumber(num: number) {
[Link](num);
}
// Output:
// 1
// 2
// 3
In this section, we explored behavioral patterns, which are essential for managing effective
communication between objects. In the next section, we will shift our focus to practical
idx_5f567eef
examples, where we will apply these patterns to real-world scenarios, such as notification
services and API integrations, to cement your understanding.
world TypeScript applications. Understanding when and where to apply a pattern is key to
writing better, more efficient code.
In this section, we'll walk through practical examples that show how each pattern solves
common architectural challenges. We begin with the creational patterns, using clear
scenarios to illustrate the problem, followed by an explanation of why a particular pattern is
the right solution.
Creational patterns
Creational patterns focus on flexible and scalable object creation. Let's start with a practical
idx_045ada0c
others prefer SMS. We need a way to send messages without hardcoding the logic for every
single type.
Why use the Factory method? To decouple the message creation from the message sending. The
main application just asks for a notification, and the Factory handles the details.
Here are the steps:
1. Define the product: First, we define the common behavior that all notifications must
share:
2. Create the factory: This class contains the logic to decide which notification type to
create based on a runtime parameter:
class NotificationFactory {
static getNotification(type: "email" | "sms"): Notification {
if (type === "email") return new EmailNotification();
if (type === "sms") return new SMSNotification();
throw new Error("Unsupported notification type.");
}
}
This example shows how the Factory method simplifies object creation and centralizes
idx_bd068d46 idx_b09ce545
challenge is integrating external systems that don't match your application's expected
interfaces.
Scenario: Your application needs to display weather data, but the two providers you integrate
with (WeatherAPI1 and WeatherAPI2) return results in different formats.
Why use Adapter? To create a unified interface. This allows your app to treat both APIs exactly
the same way, preventing the need for complex if/else logic in your UI code.
Here are the steps:
1. The problem (incompatible APIs): Here are the two services. Notice the method
names don't match (fetchTemp vs. getTemp):
interface WeatherService {
getTemperature(): number;
}
class WeatherAPI1 {
fetchTemp(): number { return 28; }
}
class WeatherAPI2 {
getTemp(): number { return 30; }
}
2. The adapters: We build wrappers that translate the external API calls into the format
our app expects:
getTemperature(): number {
return [Link]();
}
}
getTemperature(): number {
return [Link]();
}
}
3. Unified usage: Now, the client code treats both services as generic WeatherService:
By using the Adapter pattern, your application can work with any provider through a
idx_7cdd6a77 idx_17002e65
consistent interface, reducing code duplication and improving maintainability. Next, we will
look at a behavioral pattern that handles real-time updates across multiple components.
Behavioral patterns
Behavioral patterns focus on how objects interact and communicate with each other. They are
idx_36c5329c
particularly useful when changes in one part of the system should automatically trigger
updates elsewhere.
sent.
Why use Observer? It implements a push-based mechanism where the subject (ChatRoom)
notifies all observers (users) automatically, avoiding tight coupling between components
Here are the steps:
1. Define the subject (ChatRoom):
class ChatRoom {
private users: User[] = [];
addUser(user: User) {
[Link](user);
}
sendMessage(message: string) {
// Notify every user in the list
[Link]((user) => [Link](message));
251 Mastering Design Patterns in TypeScript
}
}
interface User {
notify(message: string): void;
}
notify(message: string) {
[Link](`${[Link]} received: ${message}`);
}
}
[Link](user1);
[Link](user2);
[Link]("Hello, everyone!");
// Output:
// Alice received: Hello, everyone!
// Bob received: Hello, everyone!
This example demonstrates how the Observer pattern allows multiple components to react
idx_e8833daa idx_81ad09b9
automatically to changes in a single object, keeping your system responsive and decoupled.
Next, we will explore another behavioral pattern that enables undo functionality in
applications.
Why use Command? To encapsulate the action of "writing" into an object. Because the object
exists, we can call an undo() method on it to reverse the action.
Chapter 8 252
class TextDocument {
private text: string = "";
write(text: string) {
[Link] += text;
}
getText() {
return [Link];
}
erase() {
[Link] = ""; // Simplified undo logic
}
}
2. The Command object: We create WriteCommand that knows how to execute the write
and how to reverse it:
interface Command {
execute(): void;
undo(): void;
}
execute() {
[Link]([Link]);
}
undo() {
[Link]();
253 Mastering Design Patterns in TypeScript
}
}
// 1. Execute Command
[Link]();
[Link]([Link]()); // Output: Hello, World!
// 2. Undo Command
[Link]();
[Link]([Link]()); // Output: (empty string)
The Command pattern makes it easy to implement undo/redo functionality and maintain a
idx_02710000 idx_9d2f86ae
clean separation between the user interface and action logic. This pattern can also be applied
to queues, transactions, or any operation that may need to be undone or replayed.
shines in specific situations, whether you need flexible object creation, a cleaner way to
structure complex systems, or better control over how components communicate. The
following guidelines summarize when each category is most effective:
• Creational patterns:
◦ Use Factory or Abstract Factory when you want flexibility in creating objects
without knowing their exact classes
◦ Builder is great for creating objects with many optional fields
◦ Singleton works best for managing global instances such as configuration or
logging
• Structural patterns:
◦ Use Adapter to integrate incompatible systems
◦ Composite helps manage tree structures, such as filesystems or menus
Chapter 8 254
◦ Decorator is perfect for adding features dynamically without modifying the base
object
◦ Facade simplifies complex subsystems for easier use
• Behavioral patterns:
◦ Observer works for real-time updates, such as notifications or event systems.
◦ Strategy is ideal for swapping algorithms easily
◦ Command is useful for encapsulating tasks such as undo or logging
◦ Iterator shines when iterating over custom collections
In this section, you learned the following:
idx_f3385fd0
• How to apply design patterns such as Factory, Adapter, Observer, and Command to
real-world problems
• Which patterns work best for specific scenarios, such as simplifying object creation,
managing interactions, and making systems compatible
In this section, we explored when each design pattern is most useful, which helps you choose
the right solution for the right problem. Understanding this context is essential for avoiding
misuse and keeping your architecture clean. In the next section, we'll look at the advantages
and disadvantages of design patterns to help you decide when they add value and when they
might introduce unnecessary complexity.
cases, they can introduce drawbacks that make your code harder to maintain or understand.
Being aware of these limitations helps you avoid using patterns where they do more harm than
good. Let's look at some of the most common disadvantages:
• Unnecessary complexity: Some patterns can make code more complicated than it
needs to be. For example, using the Abstract Factory pattern for a small application
with just one type of object might be overkill.
• Steep learning curve: For beginners, design patterns can be hard to understand at
first. Concepts such as Decorator or Command might feel abstract and difficult to
apply.
• Overengineering: Sometimes developers use patterns where simple code would
suffice, leading to overly engineered solutions. This is known as pattern obsession.
• Reduced performance: Some patterns, such as Decorator, may add extra layers of
abstraction that can slow down performance in resource-intensive applications.
• Not always flexible: While patterns such as Singleton provide global access to a single
instance, they can make your application rigid and harder to test, especially in larger
systems.
more complexity than they solve. Understanding these scenarios helps you choose patterns
thoughtfully and avoid unnecessary overhead. Here are some cases where patterns might
complicate your project instead of improving it:
• Small projects: For a small app or prototype, the overhead of applying patterns might
not be worth it. A simpler approach can work just as well.
Chapter 8 256
• Misuse of patterns: Using the wrong pattern for a problem can make your code harder
to understand and maintain. For instance, applying the Composite pattern to a simple
list structure might be unnecessary.
• Too many patterns at once: Mixing too many patterns in one system can make the
codebase confusing and difficult to navigate.
The following best practices will help you implement design patterns effectively in TypeScript
without introducing unnecessary complexity:
• Understand the problem before choosing a pattern:
◦ Avoid forcing a design pattern into your code. Instead, analyze the problem you
are solving.
◦ Ask yourself, Does this pattern make my solution simpler, clearer, or more flexible?
For example, use the Observer pattern when multiple objects need updates based on
changes in a central object, such as in event-driven systems.
257 Mastering Design Patterns in TypeScript
• Keep it simple:
◦ Use patterns only when they add real value. Don't over-engineer your code.
◦ If a simple function or class solves the problem, it's okay to skip a pattern.
For instance, if your app has a single global configuration, a simple object might suffice
instead of using the Singleton pattern.
• Combine patterns when necessary:
◦ Sometimes, combining patterns provides better solutions. For example, use
Factory to create objects and combine it with Decorator to add extra
functionality dynamically.
◦ Always document why the patterns are combined to help others understand the
reasoning.
• Make your patterns flexible:
◦ Write your patterns in a way that allows changes in the future. For example, use
idx_57f10b65
The comment clarifies the purpose of the class, signaling that it implements the
Observer pattern to manage updates to multiple subscribers efficiently.
Chapter 8 258
interface ICarBuilder {
setEngine(engine: string): this;
setWheels(wheels: number): this;
build(): Car;
}
This interface enforces a strict contract, ensuring that any class implementing
ICarBuilder provides the necessary methods. Crucially, using this as the return type
tells TypeScript that these methods return the builder instance itself, which enables
"fluent" method chaining (e.g., .setEngine(...).setWheels(...)) while keeping type
safety intact.
• Avoid misusing patterns:
◦ Don't apply a pattern just because it's popular. Patterns such as Abstract Factory
may be unnecessary for simple systems.
◦ Always weigh the complexity that a pattern introduces against the benefits it
offers.
• Learn from examples:
◦ Study real-world applications of patterns to understand their practical use.
◦ Practice implementing patterns in small projects to build confidence.
• Refactor when needed:
Patterns aren't set in stone. If your system evolves and a pattern no longer fits, refactor
idx_881e0bb8
Summary
In this chapter, you learned how design patterns provide proven solutions to common software
design challenges and how to implement them effectively using TypeScript. We explored what
design patterns are and why they matter, followed by a deep dive into creational, structural,
and behavioral patterns—each showing different ways to create objects, organize code, and
manage communication between components. You also walked through practical, real-world
examples that demonstrated when and where each pattern is most useful, and examined their
advantages and disadvantages to help you avoid unnecessary complexity or misuse.
Finally, we looked at best practices for applying patterns wisely and making the most of
TypeScript features such as interfaces and generics. With this understanding, you are now
prepared to use design patterns confidently to build clean, scalable, and maintainable
applications. In the next chapter, we'll move into advanced TypeScript features that will
further strengthen your ability to design robust and flexible systems.
Note: Keep your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
9
Understanding Advanced
TypeScript Features
In this chapter, we will explore some of the more complex features of TypeScript. These
features help you write better code that is easier to maintain, debug, and understand. We will
break down these concepts into simple terms with practical examples to ensure clarity.
TypeScript offers many tools and options that allow you to create flexible and powerful
applications. By mastering these advanced features, you will gain the skills to confidently
handle larger and more challenging projects while improving your overall coding expertise.
In this chapter, we will cover the following main topics:
• Exploring generics
• Introducing advanced types
• Understanding decorators
• Creating mapped types
• Using conditional types
• Working with utility types
By the end of this chapter, you will have a strong understanding of these advanced TypeScript
features and how to apply them effectively in real-world projects. These skills will prepare you
to build scalable, robust, and high-quality applications with ease.
Technical requirements
You can download the example project and code for this book by following the instructions in
the Download the example code files section in the Preface of this book.
Chapter 9 262
This chapter's code files are included in the downloadable code bundle.
Exploring generics
Generics are a powerful feature in TypeScript that lets you create flexible and reusable
idx_d5fd3eeb
components. They allow you to write functions, classes, and interfaces that can work with
different data types while keeping your code type-safe. In other words, you don't have to write
the same code multiple times for different types.
Generics are especially useful when you don't know the exact type of data your function or
class will handle, but still want to enforce type safety. With generics, you can write code that
adapts to different data types while avoiding common errors.
But why should we use generics?
• Reusability: Generics let you write code once and use it for any type
idx_e9e1f859
• Type Safety: They ensure the correct types are used, reducing runtime errors
• Flexibility: They adapt to different types without sacrificing code clarity
Now that we understand the benefits, let's look at how to implement generics in practice.
Generics at work
Generics are defined using angle brackets (<>) and a placeholder type, such as T. The T
idx_b2b2267f
represents type parameter, which will be replaced with a specific type when the code is used.
Here's a simple example of a generic function:
In this example, the wrapInArray function can work with any type (number, string, etc.), and
TypeScript infers the type of T based on the argument passed. Let's look at generic classes now.
Generic classes
Generics can also be used in classes to make them work with different types. This is
idx_cd7bfff3
particularly useful when you need to create a container that holds a group of items. Without
generics, you would have to write a separate class for every type of item you want to store,
which creates a lot of extra work.
263 Understanding Advanced TypeScript Features
class Storage<T> {
private items: T[] = [];
getItems(): T[] {
return [Link];
}
}
In this example, the Storage class can store any type (string, number, etc.), and the T type
idx_9486598f
parameter ensures type safety for the stored items. Next, let's understand interfaces in
generics.
Generic interfaces
Just like with classes, we can apply generics to interfaces to make them more flexible. This is
idx_865671a8
helpful when you want to define a standard structure for an object, but the types of data inside
it might change depending on the situation. For example, imagine you want to link two values
together. Instead of creating one interface for numbers and another for strings, you can create
a single Pair interface that handles any combination.
Here is how you can define that using generics:
Here, the Pair interface works with two different types (T and U), and this makes it easy to
define pairs of different data types. Finally, let us take a look at constraints and generics.
Generic constraints
You can restrict the types that a generic can accept using constraints. This is necessary when
idx_31d87658
your code needs to access specific properties on the generic type. For example, if you want to
write a function that logs the length of an item, you need to guarantee that the item actually
has a .length property. Without a constraint, TypeScript would stop you because T could be a
number (which doesn't have a length).
Here is how you use the extends keyword to enforce that rule:
// Valid usage
logLength("Hello"); // T is string
logLength([1, 2, 3]); // T is array
// Invalid usage
// logLength(42); // Error: number does not have a length property
In this example, the T type must have a length property, ensuring only compatible types are
idx_5a3596e7
used.
Advantages of generics
Now that we've seen how generics work in practice, it's important to understand what
idx_cccf1ab8
generics specifically enable, beyond what TypeScript's type system already provides.
Generics allow types to be parameterized, meaning they can adapt to different data types while
preserving relationships between inputs and outputs. This makes generics especially powerful
when writing reusable utilities, data structures, and abstractions.
One of the key advantages of generics is that they help avoid code duplication without
sacrificing type safety. Instead of writing the same function or class multiple times for different
types, generics allow a single implementation to work across many types while still enforcing
correct usage at compile time.
265 Understanding Advanced TypeScript Features
Generics also enable strong type relationships. For example, a generic function can ensure that
the type of its return value is directly tied to the type of its input, something that is not possible
with any or loosely typed abstractions. This prevents entire classes of bugs that would
otherwise only appear at runtime.
Finally, generics provide flexibility with precision. They allow code to remain adaptable while
still benefiting from TypeScript's static checking, autocomplete, and refactoring support. This
balance makes generics a foundational tool for building type-safe libraries, reusable
components, and scalable application architectures.
In this section, you learned how generics allow you to write reusable and adaptable code
without losing type information. By parameterizing types, generics enable flexible abstractions
that remain safe, expressive, and easy to maintain as applications grow.
In the next section, we will explore advanced types, including how to combine types, create
more specific types with unions and intersections, and handle optional or nullable values.
These features will help you handle complex type scenarios in your TypeScript projects.
They help you define types in ways that go beyond basic types. This means you can combine
and manipulate types to better fit your needs in a program. By mastering advanced types, you
can handle challenging scenarios in your applications while keeping your code clean and type-
safe.
In this section, we'll break down the most important advanced types and explain how they
work with practical examples.
• Union types: These let you define a variable that can hold multiple types. You use the
idx_0851ea0c idx_9e83cf0d
// Usage
[Link](formatValue("Hello")); // Works with string
[Link](formatValue(42)); // Works with number
Chapter 9 266
In this example, the value parameter can be either string or number. Union types are
great for handling different types in a single function.
• Intersection types: These combine multiple types into one. You use the ampersand (&)
idx_a650434b idx_2e5aadea
interface Person {
name: string;
}
interface Employee {
id: number;
}
Here, the EmployeeDetails type combines Person and Employee, and the employee
object must have all the properties of both interfaces.
idx_a6ed79ef idx_024ecdb5
• Type aliases: Type aliases let you give a custom name to a type. This is useful for
idx_e6b9506b idx_048cdb97
In this example, the Coordinate alias makes the object type more readable and
reusable.
• Literal types: Literal types allow you to specify an exact value that a variable can have.
idx_d2f35e96 idx_03d0c06e
Here's an example:
// Usage
move("up"); // Valid
// move("forward"); // Error: "forward" is not assignable to type
"Direction"
In this code, the Direction type restricts the function to only accept specific string
values.
• Nullable types: Nullable types let you handle values that could be null or undefined.
idx_f774fe36 idx_cebe8cdb
// Usage
[Link](greet("Alice")); // Hello, Alice
[Link](greet(null)); // Hello, stranger
In this example, the function can accept either string or null and handles both cases
safely.
• Mapped types: Mapped types allow you to transform existing types into new ones by
idx_c4af10f0 idx_f5d45afa
type ReadonlyType<T> = {
readonly [K in keyof T]: T[K];
};
interface Person {
name: string;
age: number;
}
age: 30,
};
Here, the ReadonlyType mapped type makes all properties of a type read-only.
• Index signatures: Index signatures define the shape of objects with dynamic keys.
idx_cdfbb201 idx_833c16fb
Here's an example:
interface StringDictionary {
[key: string]: string;
}
In this example, the StringDictionary interface can have any number of string keys
with string values.
• Conditional types: Conditional types allow you to create types based on conditions.
idx_8bf4b5ff idx_7a9dd739
Here, the IsString type checks if a type is a string and returns true or false.
In this section, you learned about advanced types in TypeScript, including union types,
intersection types, type aliases, and literal types. These features allow you to create more
complex and flexible types, making your code easier to manage and understand.
Next, we will explore decorators, a powerful feature in TypeScript that allows you to modify or
enhance the behavior of classes, methods, and properties. Decorators are especially useful
when implementing metadata-driven programming and creating reusable patterns in your
applications.
269 Understanding Advanced TypeScript Features
Understanding decorators
Decorators in TypeScript are special functions that let you change or extend the behavior of
idx_37d49dea
classes, methods, properties, or parameters. They are used as annotations in your code and
allow you to add reusable functionality without modifying the original implementation.
Decorators are commonly used in frameworks such as Angular and NestJS for tasks such as
dependency injection, metadata, and routing. Therefore, decorators are a powerful way to
write clean and reusable code.
applied to. TypeScript uses the @ symbol to apply decorators. To enable decorators in your
TypeScript project, you must turn on the experimentalDecorators flag in your [Link]
file:
{
"compilerOptions": {
"experimentalDecorators": true
}
}
Now that we have enabled the necessary configuration, let's dive into the four specific types of
decorators TypeScript supports—class, method, property, and parameter—and see how to
implement each one.
Types of decorators
TypeScript provides several kinds of decorators, each targeting a different element of your
class. Decorators use the @DecoratorName syntax and are placed directly above the class,
method, property, or parameter they modify. Under the hood, a decorator is simply a function
that receives metadata about the decorated element (such as the constructor, method
descriptor, or parameter index) and can observe, modify, or extend its behavior. In the
following examples, we'll explore how each decorator type works and what arguments it
receives. Let's walk through the most common ones and see how they're used.
• Class decorators: A class decorator modifies or adds functionality to an entire class. It
idx_84e9d38d idx_a3c56d86
@LogClass
class User {
constructor(public name: string) {}
}
class Calculator {
@LogMethod
add(a: number, b: number): number {
return a + b;
}
}
class User {
@LogProperty
name: string = "John Doe";
}
class Greeter {
greet(@LogParameter message: string) {
[Link](message);
}
}
Now that we have explored the implementation details of the four main decorator types, let's
consolidate this knowledge by examining the core advantages they bring to your code base.
Benefits of decorators
Here are the benefits of decorators:
idx_62eeab27
In this section, you learned about decorators in TypeScript and how they can enhance your
classes, methods, and properties. You saw examples of class decorators, method decorators,
and property decorators, each showing how you can add additional functionality without
changing the core logic of your code.
Next, we will explore mapped types, where you will learn how to create new types based on
existing ones, helping you manage and transform your data structures effectively.
feature is helpful when you want to work with object types, especially when you need to apply
the same transformation to all properties of a type. Mapped types are great for creating
flexible, reusable type definitions in your projects.
The syntax uses the keyof operator and a template literal to define how properties should be
modified.
Here is the basic structure:
type MappedType<T> = {
[Key in keyof T]: Transformation;
};
several common patterns, ranging from simple modifiers such as making fields optional to
complex type transformations, that demonstrate how to manipulate object structures
efficiently.
• Making all properties optional: The built-in Partial<T> type makes all properties of
a type optional. Let's recreate it using a custom-mapped type:
type MakeOptional<T> = {
[Key in keyof T]?: T[Key];
};
// Example
type User = {
name: string;
age: number;
};
• Making all properties read-only: You can use a mapped type to make all properties of
idx_1228c038
type MakeReadOnly<T> = {
readonly [Key in keyof T]: T[Key];
};
// Example
type User = {
name: string;
age: number;
};
Chapter 9 274
• Changing property types: You can modify the type of each property in an object. For
example, you can turn all properties into strings:
type ConvertToString<T> = {
[Key in keyof T]: string;
};
// Example
type User = {
name: string;
age: number;
};
• Mapping with conditional types: Mapped types become even more powerful when
combined with conditional types. You can transform properties based on their original
type.
In the following example, we'll transform only the properties that match a specific
idx_853214b1
type TransformNumbersToStrings<T> = {
[Key in keyof T]: T[Key] extends number ? string : T[Key];
275 Understanding Advanced TypeScript Features
};
// Example
type User = {
name: string;
age: number;
isActive: boolean;
};
In this example, you saw how conditional types let you selectively transform only certain
properties of a type – in this case, converting number fields into strings while leaving
everything else unchanged. This pattern is especially useful when you need fine-grained
control over type transformations.
Now that you've seen how to build your own mapped types, let's look at the built-in mapped
types TypeScript provides and how they can simplify everyday type operations.
• Reusability: You can define reusable transformations that work with any type
• Flexibility: Mapped types adapt to changes in the original type automatically
• Clarity: They help you define and enforce consistent rules for object types
Best practices
To make the most of mapped types in real projects, it's important to use them thoughtfully.
idx_a40dd1d6
The following guidelines will help you apply mapped types effectively, avoid common pitfalls,
and ensure your type transformations remain predictable as your code base grows:
• Use mapped types to simplify complex type transformations
• Combine them with utility types and conditional types for maximum flexibility
Always test custom-mapped types to ensure they behave as expected.
In this section, you learned about mapped types in TypeScript and how they allow you to
create new types based on existing ones. You saw examples of making properties optional,
readonly, and even changing their types. Mapped types help you keep your code maintainable
and adhere to the Don't Repeat Yourself (DRY) principle.
Next, we will explore conditional types, where you will learn how to create types that depend
on certain conditions, allowing for even more flexible type definitions.
conditions. They are like if-else statements for types, helping you define dynamic and
flexible type logic.
The basic structure of a conditional type is as follows:
T extends U ? X : Y;
This means if type T can be assigned to type U, then the result is type X. Otherwise, the result is
idx_24eb6f7c
type Y. Conditional types are powerful for creating dynamic type transformations and making
your TypeScript code more type-safe and adaptable.
277 Understanding Advanced TypeScript Features
Now that we have defined the syntax, let's look under the hood to see how TypeScript
evaluates these conditions and how you can use them to build intelligent type definitions.
and returns one result if true, and another if false. We will start with a basic type check to see
this syntax in action, and then explore how to use the advanced infer keyword to extract
specific details from inside a type.
• A basic example: Here's a simple example to check if a type is string or something
else:
type IsString<T> = T extends string ? "Yes, it's a string" : "No, it's not
a string";
// Example usage
type Test1 = IsString<string>; // "Yes, it's a string"
type Test2 = IsString<number>; // "No, it's not a string"
• Inferring types: Conditional types can extract or infer parts of a type using the infer
keyword. This is helpful for working with complex types.
Let's take a look at an example of extracting the return type of a function:
// Example usage
type MyFunction = () => number;
type Result = ReturnType<MyFunction>; // number
// Example usage
type AllTypes = string | number | boolean;
type Result = ExcludeType<AllTypes, boolean>; // string | number
• Handling nullable types: One of the most practical uses of conditional types is
idx_af10e377
cleaning up data structures. You can use them to automatically remove null and
undefined values from a union type, ensuring you are working with valid data.
Example: Creating a NonNullable utility: To achieve this, we define a utility called
NonNullable. It checks if the type is null or undefined; if so, it discards it (by returning
never). If not, it keeps the type.
// Example usage
type MaybeNullable = string | null | undefined;
type Result = NonNullable<MaybeNullable>; // string
• Creating flexible utility types: Conditional types allow you to define utility types for
various scenarios.
Let's use this example to check if a type is an array:
idx_f416d634
// Example usage
type Test1 = IsArray<number[]>; // true
type Test2 = IsArray<string>; // false
We have seen how conditional types work in isolation to filter or check types. But their real
power shines when they team up with other TypeScript features.
279 Understanding Advanced TypeScript Features
type OptionalIfString<T> = {
[K in keyof T]: T[K] extends string ? T[K] | undefined : T[K];
};
// Example usage
type User = {
name: string;
age: number;
};
(`) found in JavaScript. When combined with conditional types, they become
incredibly powerful, allowing you to dynamically construct specific string patterns
based on input types.
Example: Generating dynamic status messages: In this example, we use a template
literal to construct a full sentence. The specific word inside the sentence ("Success" or
"Error") is chosen dynamically based on the status code provided.
// Example usage
type Result1 = Status<200>; // "Success"
type Result2 = Status<404>; // "Error"
You now have the tools to write complex custom logic, but you don't always need to reinvent
the wheel. For many standard type transformations, TypeScript has already done the heavy
lifting for you.
conditional logic we just discussed. These utilities allow you to perform common set
idx_ce34cb66
Best practices
Conditional types are among the most powerful features in TypeScript, but with great power
idx_94a47dab
comes the potential for great complexity. Deeply nested conditions and overuse of infer can
quickly make your code unreadable to other developers. Here are some guidelines to keep your
type definitions clean:
• Start simple: Begin with straightforward conditional types before combining them
with other features
• Use for reusability: Create reusable utility types for common patterns
• Test for clarity: Always test your conditional types to ensure they behave as expected
In this section, you learned what conditional types are and how they work. You saw how to use
conditional types for type transformations. You also looked at practical examples, such as
filtering types, inferring return types, and creating utility types, and how to combine
conditional types with mapped types and other features.
281 Understanding Advanced TypeScript Features
Next, we will explore utility types, where you will learn about the built-in types that
TypeScript provides to help you manipulate and transform data types easily.
are designed to simplify common type transformations and help you write more concise,
reusable, and maintainable code. Instead of writing custom types for every scenario, utility
types save you time and reduce complexity by offering ready-made solutions.
fundamental tools for transforming object types and manipulating property statuses
(optionality, mutability, and selection). Let's examine how each one works with a simple
example.
• Partial: The Partial utility type makes all the properties of a type optional. This is
idx_4678af14 idx_7d08375e
useful when you want to work with objects that might not have all their properties
defined.
Before we move on, let's look at the syntax:
type Partial<T> = {
[P in keyof T]?: T[P];
};
Let's demonstrate how applying Partial<T> makes the originally required properties
optional:
interface User {
name: string;
age: number;
}
• Required: The Required utility type does the opposite of Partial. It makes all the
properties of a type mandatory.
Here's the syntax:
type Required<T> = {
[P in keyof T]-?: T[P];
};
interface User {
name?: string;
age?: number;
}
• Readonly: The Readonly utility type makes all the properties of a type read-only,
idx_2a2c048b idx_f3e3ab74
meaning they cannot be reassigned after initialization. This is useful for defining
immutable configuration objects.
The syntax for Readonly is shown here:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
The following example demonstrates that any attempt to modify a property after the
object has been created will result in a compilation error:
interface User {
name: string;
age: number;
}
283 Understanding Advanced TypeScript Features
• Pick: The Pick utility type allows you to create a new type by selecting specific
idx_c53540da
properties (K) from an existing type (T). This is valuable when you want to create a type
idx_fc679174
Let's look at an example demonstrating how Pick extracts only the necessary
idx_734d2aa3
interface User {
name: string;
age: number;
email: string;
}
• Omit: The Omit utility type creates a new type by removing specific properties (K) from
idx_49ac2e99 idx_a7d9dad3
an existing type (T). This is often used to pass a type to a function that doesn't need all
the data (e.g., omitting the id property when creating a new database record).
Let's look at its syntax here:
Let's demonstrate how Omit creates a new type by explicitly excluding one or more
properties from the original interface:
interface User {
name: string;
age: number;
email: string;
}
• Record: The Record utility type creates an object type with specified keys (K) and
idx_6fbe6cff
uniform values (T). This is useful when you want to enforce a map structure based on a
idx_68ef1979
Let's see how Record forces an object to adhere to a specific set of predefined keys,
ensuring every defined key has the same value type:
idx_56bb3aed idx_bcc5c7ef
admin: true,
user: false,
guest: false,
};
• Exclude: The Exclude utility type lets you start with a union type (T) and subtract
idx_7b446477 idx_02860570
certain types (U) from it, leaving only the ones you want to keep. This is helpful when
you want to narrow down a union by removing members you no longer need.
Its syntax is shown here:
Let's see how Exclude performs this subtraction and keeps only the remaining
members:
• Extract: The Extract utility type lets you start with a union type (T) and keep only the
idx_e508cf6f
members that match (U). It's essentially the opposite of Exclude: instead of subtracting
idx_2ab8cfcf
// AdminRole = "admin"
• NonNullable: The NonNullable utility type starts with a type (T) and removes null and
idx_c43992a1
undefined from it. This is useful when you want to ensure a value is always present and
idx_0baa82b7
cannot be nullish.
Chapter 9 286
function getUser() {
return {
name: "Alice",
age: 25,
};
}
Now that we've explored the core utility types—tools for making properties optional or
required, selecting or omitting keys, working with unions, and removing nullish values—let's
shift our attention to some best practices for using these utilities effectively in real-world
TypeScript projects.
Best practices
To get the most out of utility types, it's important to use them with intention. Here are key
idx_e6c1b10e
practices that will help you apply them effectively and keep your code clean.
• Use utility types for reusability: Avoid rewriting common patterns by using utility
types where possible
• Combine utility types: Combine multiple utility types to create more complex but
reusable types
• Test your types: Test the utility types you use to ensure they behave as expected
In this section, you learned what utility types are and how they simplify working with types,
and about the key utility types such as Partial, Pick, Omit, Record, and more.
You also looked at how to use these types to create reusable, maintainable code and practical
examples of each utility type.
287 Understanding Advanced TypeScript Features
Summary
In this chapter, we explored advanced TypeScript concepts such as generics, advanced types,
decorators, mapped types, conditional types, and utility types. These tools enable developers
to write more flexible, reusable, and type-safe code. By mastering these features, you can
confidently handle complex TypeScript projects and improve the quality of their code.
In the next chapter, you will learn how to apply TypeScript in real-world web development
scenarios. We'll cover topics such as integrating TypeScript with React, working with [Link]
and Express, building full stack applications, and using TypeScript with modern tools such as
webpack. The chapter will focus on practical applications, making TypeScript a core part of
your development workflow.
Note: Have your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
10
Setting Up Scalable TypeScript
Projects
You've learned TypeScript's syntax and advanced patterns—now it's time to apply that
knowledge to real-world projects. This chapter bridges the gap between theory and practice by
guiding you through the setup of a production-ready TypeScript environment that's scalable,
maintainable, and optimized for team collaboration.
You'll go beyond writing code to making architectural decisions that real teams face daily:
choosing between monorepo and polyrepo strategies, selecting the right tools for package
management, and enforcing code quality standards automatically. These are the foundational
practices that empower professional teams to build and scale full stack applications
confidently.
In this chapter, you'll create a complete development workspace using Nx, configure it with
pnpm for efficient dependency management, and automate quality enforcement using Git
hooks. You'll also learn how to set team standards, manage environment configurations, and
optimize your development workflow for long-term productivity.
By the end of this chapter, you'll have a production-grade project foundation built on smart
defaults and battle-tested tools. More importantly, you'll understand the reasoning behind
each choice—skills critical for leading technical decisions on any team or project.
In this chapter, we're going to cover the following main topics:
• From product spec to technical architecture
• Repository strategy—monorepo versus polyrepo
• Setting up your Nx workspace
Chapter 10 290
Technical requirements
In this chapter, you'll need the following tools and technologies installed on your system:
• [Link] (v18 or later)
• pnpm (v8 or later)
• Nx CLI (npx create-nx-workspace@latest)
• Git
• VSCode (recommended)
• Basic knowledge of TypeScript and terminal commands
You can download the example project and code for this book by following the instructions in
the Download the example code files section in the Preface of this book. This chapter's code files
are included in the downloadable code bundle.
product specification (or "product spec") is a concise document that outlines what you're
building and why. It's not code; it's a shared source of truth between technical and non-
technical stakeholders.
A good product spec defines the following:
• The core features of the product
• The users it serves
• The problems it solves (for both users and the business)
• Any relevant constraints (timelines, technologies, budget)
Why does this matter to you as a developer? Technical decisions shouldn't be made in
isolation. A clear product spec helps teams prioritize features, choose the right tools, and
estimate effort more accurately. Without it, code bases often evolve without direction,
resulting in rework and technical debt.
To keep this practical, let's work with a concrete example. Imagine you've been handed this
fictional specification by a product manager or start-up founder:
• DevJobs Platform Specification:
Build a modern job board specifically for developers. Users should be able to browse
idx_efe9b503
and filter job listings by technology, experience level, and location. Companies can post
jobs with clear technical requirements and salary transparency. Developers can create
basic profiles showing their skills and GitHub username.
The platform must support user authentication and allow companies to manage their
own job postings. Initial scope focuses on web-only (desktop-first), with no immediate
need for real-time updates or external API integrations. Target launch is 3 months with
a team of 3-5 engineers.
From this specification, several critical technical decisions emerge:
◦ Do we need multiple applications or a monolithic structure?
◦ How should we organize code for shared types and validation?
◦ What tooling will help our team collaborate effectively?
◦ How do we structure the code base for future growth?
In addition to these core code-related decisions, teams often consider other factors such as
whether to use serverless architectures or traditional servers, which database technology to
adopt, or how to structure deployment pipelines. While these topics are crucial, this book
Chapter 10 292
focuses primarily on writing clean, maintainable TypeScript code and designing robust
application architecture. We will therefore keep the discussion centered on the code base itself
and the collaboration around it.
Team size and skill levels also influence decisions. Smaller or less experienced teams may
idx_84ee8706
prefer simpler architectures and fewer moving parts to reduce complexity, while larger teams
might benefit from modular designs and clear separation of concerns to enable parallel
development.
Once we have a product spec in place, we've done more than just capture an idea; we've
defined the foundation every technical choice will rest on. At this stage, the question shifts
from What are we building? to How are we going to build it?.
why. But knowing that isn't enough to start coding. Before writing a single line, we need a
technical plan: a high-level architecture that's concrete enough to guide decisions, but not so
detailed that we get stuck in theory.
Think of it like a building project. The product spec is the client's wish list: We want a three-
story office with a rooftop terrace. The technical plan is the architect's drawing measurements,
layouts, and structural notes that engineers can actually build from.
In software, this means translating the product specification into clear requirements,
constraints, and guiding choices that developers will reference throughout the project. In
larger organizations, this role often falls to a dedicated software architect; in smaller teams, it's
typically handled by one or more senior engineers. Either way, there needs to be a technical
plan, or more specifically, a system design. While full system design is beyond the scope of this
book, we'll take a quick walk through the process so you can see how it works, and how it
influences the way we structure the project and, eventually, our code.
Let's look at the thought process behind creating this technical plan—step by step—and see
how we go from a product specification to the high-level architecture decisions that will guide
idx_dad56ee6
to re-describe the project, but to translate the "what" into actionable technical requirements
and constraints that shape how the system will be built.
Let's go over the following steps to build one:
293 Setting Up Scalable TypeScript Projects
technical.
• Technical constraints: These capture the system's operational demands and
idx_89e00a18
limitations. They typically include factors such as the expected number of daily active
users, the peak number of concurrent sessions, the type and size of data that needs to
be stored, and the frequency of incoming requests. Each of these elements directly
influences how the system is designed, scaled, and maintained. To make informed
Chapter 10 294
◦ [Link]
• Non-technical constraints: These reflect organizational and team realities that shape
idx_33d74134
architectural choices. They may include the size of the development team, delivery
deadlines, and the specific skills and experience the team brings. For instance, if the
team consists of three to five developers working toward a three-month deadline, that
scope will naturally favor simpler solutions. Similarly, if the team has strong React
expertise, React Native may be a more practical choice for mobile development than
Flutter. On the other hand, if the team has limited experience with Kubernetes or
complex DevOps setups, opting for a serverless architecture can help reduce
operational overhead and risk.
How constraints shape architecture
With both technical and non-technical constraints in mind, architectural decisions become
clearer. Here are some examples:
• For storing company logos and CVs, you may need object storage (e.g., Amazon S3)
alongside a relational database for job and user data idx_f418f0dc
• Given a tight timeline and limited DevOps expertise, a serverless deployment may be
idx_6549708b
everyone knows why a decision was made and what trade-offs were considered. Without
them, decisions live only in conversations and are easily lost.
295 Setting Up Scalable TypeScript Projects
This prevents decisions from being lost in chat threads or meetings, and it gives the team a
single reference point as they work. The simplest and most effective way to do this is through
ADRs.
ADRs are short, version-controlled documents that capture the following:
• Context: why a decision was needed
• Decision: what option was chosen
• Consequences: trade-offs or follow-up actions
Unlike long design documents, ADRs stay close to the code base and remain lightweight. This
means they're practical enough for small teams while still valuable for larger organizations.
technology. We'll save it in a dedicated folder inside our repository, such as /docs/adr/.
Create a new file in your project here:
/docs/adr/[Link]
Every developer on the team can understand the context in which PostgreSQL was chosen,
including the constraints, requirements, and trade-offs that shaped the decision. Later, if the
team considers switching technologies, they won't have to start from scratch—they can review
the original reasoning, assumptions, and conditions that influenced the choice.
As we continue, you can repeat this process for other key choices, such as the following:
• Monolith versus microservices
• Deployment strategy (serverless versus managed servers)
• Authentication and authorization approach
These don't need to be long documents—just enough to capture the reasoning.
or polyrepo approach. Making the right decision early on can do the following:
• Simplify collaboration across teams
• Streamline CI/CD pipelines
• Improve scalability and maintainability
By understanding the benefits and trade-offs of each strategy, you'll be better equipped to
structure your DevJobs code base for long-term productivity.
We'll begin by defining both approaches:
• Monorepo: A monorepo (short for monolithic repository) is a single repository that
idx_ae7f8cb1
contains all the code for multiple projects or services. This can include frontend apps,
backend services, shared libraries, and utilities—all living together in one place.
• Polyrepo: A polyrepo (or multiple repositories) approach keeps each project, service, or
idx_64bc8d19
library in its own separate repository. Each team or service can evolve independently,
and changes are isolated to that repository.
Now that we know what they are, let's look at why you might choose one over the other and
how each affects your team's workflow.
297 Setting Up Scalable TypeScript Projects
Deployment requirements
• If your services need to be deployed independently, polyrepos make it easier to release
updates on their own schedule
• If you prefer coordinated releases (for example, frontend and backend always ship
together), a monorepo simplifies the process
Code sharing needs
• A monorepo makes it easier to share common libraries, types, or utilities, since they
live in the same repository
• A polyrepo can make code sharing more complex, as it often requires publishing
shared code as packages and managing versions across repos
Now that you know about the factors that influence our choice between monorepo and
idx_8a92fed2 idx_6e24d3fb
polyrepo, let's see what our strategy would be for our DevJobs application.
grows. There are several options available, but in the next section, we'll look at Nx, a popular
choice for TypeScript projects that provides advanced tooling, intelligent builds, and
enterprise-ready features.
it effectively. As projects grow, a monorepo can become complex: builds slow down, teams
need clear boundaries, and shared libraries must stay consistent. Without the right tooling,
these challenges can quickly outweigh the benefits.
This is where Nx comes in. Nx is a build system and monorepo manager designed with
TypeScript projects in mind. It provides intelligent tooling, advanced configuration, and
enterprise-ready features that make working with a monorepo not just feasible but highly
productive.
299 Setting Up Scalable TypeScript Projects
developers:
• Advanced tooling: Nx comes with generators for creating applications, libraries, and
configurations. This ensures consistency and reduces repetitive setup.
• Intelligent builds: Instead of rebuilding everything, Nx analyzes the project graph to
rebuild or retest only what's affected by a change. This keeps builds and tests fast, even
as your project grows.
• Extensibility: Nx provides plugins for popular frameworks such as React, [Link],
Express, and NestJS, allowing you to scale your stack without reinventing the wheel.
Together, these features help you maintain a professional-grade project structure that stays
idx_01527a3f idx_1ad4de1b
parallel execution. It's fast, simple to adopt, and integrates well with most CI
environments.
Why Nx still: Nx offers similar caching capabilities but goes further with built-in code
generators, project graph visualization, and deep TypeScript integration, making it
easier to manage large, structured code bases over time.
• Lerna: Once the go-to monorepo management tool, primarily focused on versioning idx_3153f9f3
and publishing packages. It pairs well with Yarn or npm workspaces but lacks
intelligent builds or native TypeScript support.
Why Nx still: Nx provides dependency-aware builds, incremental testing, and
automated scaffolding, supporting modern TypeScript workflows that extend beyond
Lerna's scope.
• Rush: A Microsoft-backed monorepo manager built for very large organizations with idx_d42345eb
hundreds of packages. It's powerful but requires more setup and can feel heavyweight
for smaller teams.
Chapter 10 300
Why Nx still: Nx delivers similar scalability while remaining lightweight. Its plugin
ecosystem and affected-command system make it flexible for growing teams yet robust
enough for enterprise projects.
• Yarn Workspaces: Useful for managing dependencies across multiple packages and
idx_e074913f
choice.
You'll create the workspace, explore its structure, and configure it to support both frontend and
backend applications. By the end, you'll have a solid foundation that makes development, code
sharing, and scalable architecture much easier.
This command will launch an interactive series of prompts. Here's a quick overview of what
you'll see and the choices we recommend for our project:
• Prettier for code formatting
◦ Prompt: Would you like to use Prettier for code formatting?
◦ Choice: Yes
◦ Reason: Ensures consistent code style across your project and makes
collaboration easier
301 Setting Up Scalable TypeScript Projects
• CI provider
◦ Prompt: Which CI provider would you like to use?
◦ Choice: Skip
◦ Reason: We'll cover CI/CD configuration in a dedicated chapter, so we can defer
this step
• Remote caching
◦ Prompt: Would you like remote caching to make your build faster?
◦ Choice: Skip
◦ Reason: Optional for now; we can configure caching later for performance
optimization
Nx will then create the workspace, install dependencies with pnpm, and set up the initial
idx_f1ad02c0
project structure.
With the workspace in place, the next step is to understand the generated folder structure
and configuration files before creating applications and libraries.
idx_6d34eeae
foundation of your project. Understanding these early on will help you navigate and configure
your monorepo efficiently.
Top-level folders
• .vscode: Contains workspace-specific VS Code settings. This helps maintain consistent
editor behavior for all developers on the project.
• node_modules: Standard folder where dependencies are installed. Since we're using
pnpm, packages are symlinked for faster installs and smaller disk usage.
• packages: This is where your applications and libraries will live. Nx encourages
organizing code into reusable libraries and independent apps here.
Important configuration files
• .gitignore and .gitkeep: Standard Git files for ignoring unwanted files and ensuring
empty folders are tracked.
• .npmrc: Configures npm/pnpm behavior for this workspace.
• [Link]: Nx-specific configuration, including workspace-wide settings, tags, and
default project behaviors.
• [Link]: Contains dependencies, scripts, and metadata for the workspace.
Chapter 10 302
inside it. For DevJobs, we'll structure the workspace around three main parts: a frontend
application, a backend application, and shared packages.
• Frontend application: The user-facing React/[Link] app where job seekers can browse
postings, search by filters, and view company details
• Backend application: An Express or NestJS service that powers the API, handling job
listings, authentication, and business rules
• Shared packages: TypeScript libraries for code that needs to be reused across frontend
and backend, such as data models, utility functions, or domain-specific logic
idx_56e0b563
documented convention uses apps/ for applications and libs/ for libraries. To ensure clarity
and consistency with many existing projects, we'll adopt this structure.
To follow this convention, let's first remove the default packages/ directory:
rm -rf packages
303 Setting Up Scalable TypeScript Projects
Now, let's create the directories that will house our applications and libraries by running the
following.
Your workspace now has clean, dedicated folders where applications (apps/) and libraries
(libs/) will live. Next, we'll continue with installing the relevant plugins.
Nx organizes functionality into plugins, which are like toolkits for specific frameworks and
technologies (e.g., [Link], Express, Angular). Each plugin comes with the following:
• Generators: These are scripts that automatically create or modify files in your
idx_dbd1febb
nx g @nx/next:app my-app
This command runs a generator that creates a new [Link] application. Behind the
scenes, it copies template files, sets up configuration such as [Link], updates
workspace configuration files, and installs any required dependencies. In other words,
generators automatically build the structure of your app or library so you don't have to
set everything up manually.
• Executors: These are like specialized scripts that know how to build, serve, or test theidx_1a835186
• The Express plugin for creating and running our backend API idx_3a92d419
Chapter 10 304
Now that the required plugins are installed, we'll generate our applications inside the apps/
idx_b512953c
new app at the repository root; to keep our apps/ convention, we prefix the path:
• Would you like to use the App Router (recommended)? → Yes: We're opting for
[Link]'s modern App Router since it simplifies routing, enables server components,
and aligns with current best practices.
• Would you like to use the src/ directory? → Yes: Placing code under src/ keeps the
project structure clean and avoids mixing configuration files with source code.
Once the setup finishes, Nx will generate the app along with a matching E2E project (apps/
idx_1b14c0e0
devjobs-frontend-e2e/). We'll keep the E2E project in place but defer its implementation
details until later. See the following screenshot for more details:
We can now try to start the application by running the following command:
This should start the application. Mine is on port [Link] That's the default,
but you should see the localhost address on your terminal. If you navigate to that URL in your
browser, you should see our web app as shown in the following screenshot:
Chapter 10 306
Great, now that we have our frontend application in place, let's generate the backend Express
idx_86fc7b6d
application.
Generate the backend (Express) application
To generate the scaffold for the API service with Express, we run the following command:
idx_c02b8c73
Nx will ask a couple of setup questions. Here are the answers we chose and why:
• Which linter would you like to use? → ESLint: As with the frontend, ESLint ensures
code quality and consistency across the workspace. Keeping the same linter across apps
reduces context switching.
• Which unit test runner would you like to use? → Jest: Jest integrates smoothly with
Nx and provides fast, reliable unit testing. Using the same test runner for both the
frontend and backend makes our tooling consistent.
Once complete, Nx generates the apps/devjobs-backend/ application along with a matching
E2E testing project, (apps/devjobs-backend-e2e/). As with the frontend, we'll leave the E2E
setup in place but won't dive into its details just yet.
307 Setting Up Scalable TypeScript Projects
Your project should look like what we have in the following screenshot:
Once the application is started, you should see the backend URL ([Link]
api) displayed in the terminal. If you navigate to that address in your browser, you should see
the message "Welcome to devjobs-backend!", as shown in the following screenshot.
With our workspace scaffolded and the core projects in place, the next step is learning how to
work effectively within Nx. While Nx sets up a lot for us automatically, most of our day-to-day
development boils down to a handful of powerful commands. By understanding these
commands early, you'll be able to run applications, test changes, and visualize dependencies
with confidence.
Chapter 10 308
look at how to actually work with them. Nx provides a small set of commands that cover most
of what you'll do day-to-day: running apps in development, building for production, testing,
and analyzing how projects depend on each other.
Before diving into the commands themselves, it's important to understand one key concept:
targets.
A target is simply a named task that you can run for a project—for example, dev, serve, build,
idx_cce6ea78
or test. Every Nx project comes with its own set of targets, and you run them with a consistent
command pattern:
nx <target> <project-name>
For example, to start our frontend in development mode, you'd run the following:
nx dev devjobs-frontend
You can think of targets as buttons on a control panel for each project. Sometimes the wiring
(configuration) for these buttons could live in [Link], [Link], or [Link],
depending on the plugin, but pressing the button looks exactly the same to you as a developer.
Let's look at the most common ones:
• nx serve <project>: Runs a project in development mode. This is the standard way to
start backend apps (e.g., Express, NestJS) and can also be used by frontend apps
depending on the plugin.
• nx dev <project>: Used by some frontend frameworks such as [Link] to run the app
in development mode with hot reloading.
• nx build <project>: Compiles the project into a production-ready output.
• nx test <project>: Runs the unit tests for the project using the chosen test runner.
So, in our case, here's a list of the commands we would need:
idx_8af60ed8
With our DevJobs workspace set up, the frontend and backend applications generated, and the
key Nx commands understood, we now have a solid foundation for development. Before
writing more code, it's a good practice to put automated quality checks in place so that every
commit meets your team's standards.
While pnpm manages dependencies efficiently behind the scenes, our focus now shifts from
project setup and task execution to enforcing code quality. This is where Git hooks come in—
lightweight scripts that run at specific points in your workflow, such as before a commit or
push, helping prevent errors and maintain consistency.
In the next section, we'll explore how to configure Git hooks using tools such as Husky and
idx_e160c83d
lint-staged, so checks run automatically on staged files and ensure your code meets agreed-
idx_1a7c22c3
relationships, and finally look at affected commands, which make CI/CD pipelines smarter by
running tasks only where changes are detected.
commit and push adheres to consistent standards. Manual checks can be error-prone and
time-consuming, so we turn to Git hooks to automate this process.
Git hooks are scripts that run at specific points in your Git workflow—for example, before a
commit or a push. They allow us to enforce code quality rules, run tests, and prevent
problematic code from entering the repository. By integrating tools such as Husky and lint-
staged, we can run checks only on files that have changed, keeping the workflow fast and
developer-friendly.
In this section, we'll cover the following:
• Git hooks strategy: Deciding where and how to enforce checks
• Husky + lint-staged setup: Installing and configuring the tools
• Advanced quality gates: Commit message standards, branch protection, and handling
hook failures
By the end, your DevJobs monorepo will automatically enforce key quality practices, helping
maintain a healthy, consistent code base across the team.
Chapter 10 310
automated checks. Choosing the right hook for the right purpose is crucial—we want to
enforce standards without slowing developers down unnecessarily.
The two most common hooks for code quality automation are as follows:
• Pre-commit: runs before a commit is created.
◦ Best for fast checks such as linting, formatting, or running tests on staged files
only
◦ Ensures low-level issues are caught early, right on the developer's machine
• Pre-push: runs before pushing commits to a remote repository.
◦ Better suited for heavier checks, such as full test suites or integration checks
◦ Ensures that broken or untested code never reaches the shared repository
To balance thoroughness with a smooth developer experience, employ pre-commit hooks for
quick, lightweight checks that complete in just a few seconds, ensuring rapid feedback without
disrupting workflow. For more comprehensive and potentially time-consuming checks, utilize
pre-push hooks, which allow for deeper validation while keeping the commit process efficient.
By combining the two wisely, you get a workflow that enforces consistency without frustrating
developers with long feedback loops.
Before setting up Git hooks, make sure you commit all your current changes. This ensures a
clean slate and prevents hooks from interfering with ongoing work. You can commit
everything with the following:
git add .
git commit -m "chore: setup Nx workspace with frontend and backend apps"
With a clean repository state, we're now ready to automate quality checks using Husky and
idx_69fa61c7
lint-staged.
example, before a commit (pre-commit) or before pushing (pre-push). Instead of writing these
scripts manually, we use Husky to manage Git hooks, and lint-staged to run commands only
on the files you're about to commit.
311 Setting Up Scalable TypeScript Projects
The -w flag tells pnpm to install these dependencies at the workspace root, since Git hooks apply
to the whole repo rather than a single project.
git init
git add .
git commit -m "chore: initial commit"
At this point, your workspace root should have a .husky/ folder with an example hook inside.
See the following screenshot for more details:
idx_3364a272
that ensures only the files you've staged for commit get linted and formatted, which keeps
checks fast and makes sure that only clean code gets into your Git history.
With Husky v9, we create the pre-commit hook manually:
This generates a .husky/pre-commit file and makes it executable. Inside, it runs pnpm exec
lint-staged whenever you attempt a commit.
#!/bin/sh
. "$(dirname "$0")/_/[Link]"
With our husky file in place, the next step is to configure lint-staged so it knows what to do
with staged files. Without a config, running pnpm exec lint-staged won't actually run any
checks.
You can define the lint-staged configuration in either [Link] or a dedicated config file.
To keep things clean and easier to manage, we'll use the .[Link] option.
Create a new file named .[Link] in the root of your repository and add the
following:
{
"*.{js,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.json": "prettier --write",
"*.md": "prettier --write"
}
This tells lint-staged to run ESLint and Prettier on staged JavaScript/TypeScript files, and
Prettier on JSON and Markdown files.
313 Setting Up Scalable TypeScript Projects
git add .
git commit -m "chore: update code"
Here's what happens when you run the preceding command in your terminal:
Husky triggers the pre-commit hook.
• lint-staged runs ESLint and Prettier only on the files you staged
• If the linters fix issues, they'll update the staged files before the commit goes through
• If errors remain (such as linting rules that can't be auto-fixed), the commit is blocked
until you resolve them
This way, you and your team can be confident that only properly formatted, linted code makes
idx_bc74d4a0
formatting. This will allow us to observe how the pre-commit hook automatically fixes staged
files before committing.
touch apps/devjobs-frontend/src/[Link]
Figure 10.6 – [Link] is a random unformatted file to test our Git hooks
When you run that command, you should see something like what's shown in the following
idx_a27794ba
screenshot:
315 Setting Up Scalable TypeScript Projects
Figure 10.7 – Terminal showing Git hooks at work, after a commit is made
Figure 10.8 – [Link] is now properly formatted after Husky + lint-staged have run.
This simple example demonstrates how Husky and lint-staged work together to enforce code
quality automatically before code enters your repository. While here we focused on linting and
formatting TypeScript files, Git hooks can be extended to enforce many other standards and
workflows.
Here are some examples:
• Run unit or integration tests on staged files before committing
• Check commit messages to ensure they follow a conventional format using tools such
as commitlint
• Enforce security or dependency checks on changed files before pushing
• Verify documentation or Markdown formatting automatically
Using these hooks effectively helps maintain a clean, consistent, and reliable code base across
both frontend and backend projects, all without slowing down developers.
idx_0b967b25
Summary
In this chapter, we focused on building a solid foundation for scalable TypeScript projects. We
started by structuring a full-stack Nx workspace, clearly separating the frontend, backend, and
shared libraries. You learned how to manage dependencies and tooling efficiently using pnpm
workspaces, ensuring that your code base remains consistent and maintainable. We then
introduced automated code quality measures using Husky and lint-staged, so that linting
and formatting happen automatically before commits, helping maintain a clean Git history.
This setup prepares your workspace for collaboration, enforces standards, and lays the
groundwork for team productivity.
At the end of this chapter, your workspace includes a [Link] frontend application (devjobs-
frontend), an Express backend application (devjobs-backend), a shared libs/ directory for
reusable utilities and types, and pre-commit Git hooks that automatically enforce linting and
317 Setting Up Scalable TypeScript Projects
formatting rules. While this chapter focused primarily on workspace setup and automation,
the next chapter will shift toward building real features. You will begin applying TypeScript
across the stack, developing type-safe React components for the frontend and building server
logic with Express for the backend. A key goal will be sharing types between the frontend and
backend to improve maintainability and reduce bugs.
Note: Keep your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
11
TypeScript in Action: Building
Full Stack Applications
In the previous chapter, we established the groundwork for scalable TypeScript projects. We
moved from defining a product specification all the way to setting up the monorepo for our
devjobs portal using Nx. We also saw how to set up automated checks and enforce coding
standards with Git hooks.
With both the backend and frontend scaffolding in place, this chapter focuses on bringing the
project to life. We'll begin with a NestJS backend, defining type-safe APIs with validation, and
then move on to the React frontend, where we consume those APIs using shared Data Transfer
idx_0dabe34e
Objects (DTOs). Along the way, we'll explore how TypeScript strengthens collaboration by
reducing runtime errors and ensuring consistency between the client and server.
By the end of this chapter, you'll have a production-ready, type-safe full stack application that
demonstrates the real power of TypeScript in modern web development.
We will cover the following main topics:
• Server-side TypeScript with [Link]
• Client-side TypeScript with React
Technical requirements
To follow along with this chapter, you will need to have completed the project setup in Chapter
10. You can download the example project and code for this book by following the instructions
in the Download the example code files section in the Preface of this book.
This chapter's code files are included in the downloadable code bundle.
Chapter 11 320
JavaScript outside the browser. This opened the door to full stack development, where teams
could build both frontend and backend applications using the same language. With
TypeScript, this story gets even stronger: we gain type safety, better tooling, and a more
reliable foundation for collaboration across the stack.
In this section, we'll focus on developing the backend for our devjobs application using
TypeScript and NestJS. Our goal is to see how professional teams approach building type-safe
APIs that improve collaboration with frontend developers and reduce runtime errors. Since
we've already set up the project structure, we'll dive straight into the implementation, starting
with the contract-first approach, where we define shared schemas and DTOs, lightweight data
structures that standardize how information is exchanged between the client and server,
serving as the single source of truth for both.
straight into backend implementation details, we will begin by defining the expected schemas
idx_6d84abb6
for our API—that is, the request and response data types, along with the available methods.
These definitions are usually created in close collaboration with frontend developers or any
other teams that will consume the API.
Once both sides agree on what the API should provide, this agreement becomes the contract.
In practice, this is often captured using OpenAPI documentation (formerly known as
idx_d7178a83
• Shared source of truth: Both backend developers and consumers work from the same
agreed-upon schema, removing ambiguity.
• Parallel development: Frontend developers don't have to wait for backend
implementation. Since they already know the expected responses, they can mock data
and continue building their features in parallel. This saves significant development
time.
321 TypeScript in Action: Building Full Stack Applications
• Backward compatibility: Because the contract specifies the shape of data, any
refactoring or internal changes on the backend must still honor the same output. This
keeps clients stable, even as the backend evolves.
• Type safety across the stack: Teams don't need to redefine or duplicate types. Instead,
they can generate typings directly from the OpenAPI documentation, ensuring
consistency between the backend and frontend.
idx_d6917bf7
Now that we understand the what and why of the contract-first approach, let's start building
by going through the following steps.
for now).
With the file in place, we can now start defining our schemas and what the API would look like.
idx_6196dc9d
request paths, and other relevant details. The full specification is already defined in the
repository under devjobs-backend/[Link].
Here's a sneak peek of what the file looks like (truncated for brevity):
idx_14ac89fc
openapi: 3.0.3
info:
title: DevJobs API
version: 1.0.0
Chapter 11 322
servers:
- url: [Link]
description: Local development server
paths:
/users:
get:
summary: List all users
responses:
'200':
description: Array of users
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
post:
summary: Register a new user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUser'
responses:
'201':
description: Created user
content:
application/json:
schema:
$ref: '#/components/schemas/User'
323 TypeScript in Action: Building Full Stack Applications
• /users
◦ GET: List all users
◦ POST: Register a new user
• /companies
◦ GET: List all companies
◦ POST: Create a new company
• /jobs
◦ GET: List all jobs
◦ POST: Create a new job posting
Each of these endpoints references schemas defined under components, including the
following:
• User/CreateUser
• Company/CreateCompany
• Job/CreateJob
These schemas describe request/response structures and required fields (e.g., id, name, email,
idx_3a31865f
title, etc.).
For the full API specification, refer to the [Link] file in the project repository.
Now that we have our OpenAPI spec in place, the next step would be to generate types for our
project. These types are going to be generated directly from the OpenAPI spec allowing for
consistency between the frontend and the backend. To generate the types directly from the
OpenAPI spec, we would use some packages in the next step.
The preceding command installs Orval as a dev dependency. Once we have that in place, we
need to create the [Link] file in the root of our project directory and add the
following:
export default {
devjobs: {
input: './apps/devjobs-backend/src/[Link]',
output: {
schemas: './libs/api-types/model',
target: './libs/api-types',
},
},
};
This snippet is a configuration object for Orval. Let's break it down piece by piece:
idx_ab9621c3 idx_3c911bea idx_4ac5bd67
• export default { ... }: This exports the configuration object so Orval can read it
when you run the generator.
• devjobs: { ... }: devjobs: This is just a key to identify this configuration. You can
have multiple configurations in the same file if your project has multiple APIs.
• input: './apps/devjobs-backend/src/[Link]': This points to the OpenAPI
spec file you want Orval to use. Orval reads this file to understand your API endpoints,
idx_7fbd0e43
npx orval
That should generate the relevant types in the corresponding directory as shown in the image
below:
The files under the model directory should contain the types for each entity. For example, in the
preceding TypeScript file, we define the interface for Company.
With our shared API contract in place, we can now move on to building the backend. Let's start
idx_e557c83c
• [Link]
• [Link]
Let's go over what each of these does:
• [Link]: This is the root module of our application. In NestJS, modules are
containers that group together related code. The AppModule wires up our controllers,
services, and any imported feature modules. As our project grows, we'll add new
modules (e.g., UsersModule, CompaniesModule) and import them here.
• [Link]: Controllers define the routes of our application. If you've used
Express, think of them as route handlers ([Link]('/jobs', ...)). Controllers receive
incoming requests and return responses, but they don't hold the actual logic—they
delegate that to services.
• [Link]: Services hold the business logic. Instead of writing all logic in the
controller, we push it down into a service. That way, controllers remain thin (just
idx_33114923
handling requests/responses), while services handle the heavy lifting (fetching from
the database, enforcing rules, etc.).
So, in short, we have the following:
• Modules: Organize features
• Controllers: Define routes (such as Express)
• Services: Business logic layer
Now that we understand the defaults, let's see how this grows in practice.
src/
├── [Link]
├── [Link]
├── [Link]
├── [Link]
└── users/
├── [Link]
├── [Link]
└── [Link]
327 TypeScript in Action: Building Full Stack Applications
• [Link] holds the business logic (e.g., fetching users from a database)
Each module is self-contained; it has its own controller (routes) and service (logic). Finally, to
make Nest aware of this new feature, we'd import UsersModule into AppModule. This way, our
idx_b390e7a5
application grows in a clean, modular way: each feature gets its own space, making things easy
to organize, scale, and maintain. Your AppModule should now look like this:
// [Link]
@Module({
imports: [UsersModule], // registering feature module
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
This modular approach is one of Nest's biggest strengths: instead of dumping everything into
one place, we organize features into separate modules.
Now that we have a basic understanding of how Nest handles things, let's go back to our
product spec and build out the relevant features.
@Controller('jobs')
export class JobsController {
constructor(private readonly jobsService: JobsService) {}
@Get()
findAll() {
return [Link]();
}
}
Users module
Our API contract defines a User schema, along with RegisterUser for creating accounts. That
idx_227ee454
Running the preceding command creates a new users feature directory inside our backend app
directory. You should now have a structure like this:
apps/
└── devjobs-backend/
└── src/
└── app/
└── users/
├── [Link]
├── [Link]
├── [Link]
├── [Link]
└── [Link]
@Injectable()
export class UsersService {}
That's fine; we'll flesh this out soon. The important thing is that we now have the scaffolding
in place: a module, a controller, and a service, all wired together the "NestJS way."
As stated previously, remember to import UsersModule into AppModule, so NestJS knows about
idx_c4bfdd7f
it. With that in place, we are going to connect our first endpoint, POST /users, to the API
contract, and then we can start writing some business logic in UserService.
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
register(@Body() body: any) {
return [Link](body);
}}
start with a simple in-memory implementation: we'll store users in an array within the service
idx_288c5ca8
(no database yet), generate a unique ID for each user, and return the created user. This keeps
things lightweight for now.
Before saving a new user to the store (an in-memory array for now), we should use a package
known as bcrypt to hash passwords. Storing plaintext passwords is a big no-no in production.
idx_2453d68a
Hashing prevents storing the actual passwords and protects users. We will go through this step
by step and show the code updates so you can follow along easily.
The preceding command installs bcrypt and its types in the root of your workspace.
With this installed, we can now focus on the actual register method in UserService.
331 TypeScript in Action: Building Full Stack Applications
this down into smaller, actionable substeps so you can follow along easily. Each substep
includes a code snippet that you can copy-paste or adapt into your file:
1. Define an in-memory users store. We keep registered users in a simple array:
@Injectable()
export class UsersService {
private users: User[] = []; // Temporary in-memory storage
}
2. Create a register method. This method will take a CreateUser object (coming from your
API contract types that we generated previously) and return a UserResponse:
3. Hash the password. We never want to store plaintext passwords. Use bcrypt to hash the
idx_ce91acc5
5. Save the user in memory. Push the new user into the users array:
[Link](newUser);
6. Return the user (without the password). We don't want to leak the password back in
the response. Strip it out before returning:
With everything in place, your register method on UserService should look like this:
idx_06a60f6e
const newUser = {
id: randomUUID(),
name: [Link],
email: [Link],
role: [Link],
companyId: [Link],
password: hashedPassword,
};
[Link](newUser);
return userWithoutPassword;
}
Now that we have a basic registration flow in place, let's add a method to retrieve users by their
idx_06242fbc
email address. This is essential for authentication, as we'll need to look up users during login.
Now, with our service logic in place, we can make a request to our user endpoint to register a
user.
{
"name": "John Doe",
"email": "john@[Link]",
"password": "securePassword123",
"role": "user",
"companyId": "company-uuid-123"
}
{
"id": "e9e330ae-31bb-4d1c-b202-f7afca956772",
"name": "John Doe",
"email": "john@[Link]",
"role": "user",
"companyId": "company-uuid-123"
}
Congratulations! You've successfully completed your first user registration flow. Your
controller is now correctly handling requests to the /users route and delegating them to the
service layer. As you saw, a user was created with a generated ID, confirming that everything is
wired up properly.
Before we move on, let's add a small but important enhancement to UsersService: a method
idx_524f2fa1
to retrieve users by their email address. This will be crucial when we implement authentication
in AuthModule.
memory users array for a user with a matching email address and return the full user object
(including the hashed password, which is needed for comparison during login).
Here's how to implement it:
This method is simple and efficient for our current in-memory setup. Later, the logic can be
updated for when working with a database. With this in place, we can now move on to our
Auth module, which will handle the authentication process.
service. It's different from authorization, which is like checking what permissions the entity
idx_312b2d79
has; for example, a person with the role of company can create a job, whereas a regular user
can't.
In this section, we are going to generate the Auth module. We will create an auth controller and
a route to handle login requests (POST /auth/login). We will also validate user credentials
using bcrypt, and issue JWT tokens to prove the identity and role. Once that is in place, we will
add guards so that only authenticated users can hit protected routes.
Running the preceding commands should create the relevant files, just like when we worked
on the Users module. In the next step, we will start configuring the Auth module.
With our imports in place, we can move on to configuring the Auth module.
idx_ae6cddef
@Module({
imports: [
UsersModule,
PassportModule,
[Link]({
secret: 'super-secret-key', // replace with env var in production
signOptions: { expiresIn: '1h' },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
Chapter 11 336
[Link]({
privateKey: [Link].JWT_PRIVATE_KEY,
publicKey: [Link].JWT_PUBLIC_KEY,
signOptions: {
algorithm: 'RS256',
expiresIn: '1h',
},
});
When you see RS256 or similar algorithms, it usually indicates an asymmetric key setup
designed for higher security and better scalability across services.
With the Auth module configured, the next steps will involve creating the authentication logic
within AuthService, setting up AuthController to handle requests, and implementing
strategies for user validation and token generation.
idx_998b038e
337 TypeScript in Action: Building Full Stack Applications
With these in place, we inject UsersService and JwtService into the constructor so they're
available throughout the class:
@Injectable()
export class AuthService {
constructor( private usersService: UsersService,
private jwtService: JwtService,
) {}
//......
}
Next, we create a validateUser method. This checks whether the user exists and verifies the
password using bcrypt. If the credentials are correct, we return the user object without the
password; otherwise, we return null:
Finally, we implement the login method. It calls validateUser and, if the credentials are valid,
idx_230c596c
This flow ensures that only valid users receive a signed JWT, which they can use for subsequent
authenticated requests.
This method starts by calling validateUser, which checks the user's credentials and returns
null if the credentials don't match. If null is returned, the login method throws an error; else,
it signs the payload with jwtService and returns a token that can be used for subsequent
requests.
With the login logic in place for our service class, we can now expose it via the controller.
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
async login(@Body() body: { email: string; password: string }) {
return [Link]([Link], [Link]);
}
}
339 TypeScript in Action: Building Full Stack Applications
functionality.
Before testing, confirm that UsersService is exported from UsersModule. This allows
AuthModule to inject it properly:
@Module({
providers: [UsersService],
exports: [UsersService], // Add this line
})
export class UsersModule {}
Since we store users in memory, we must create a new user each time the app restarts.
idx_dc124aa6
To register a user, you can make a request via Postman or Bruno, as shown previously, or you
can just make a curl request by copying and pasting the following in your terminal:
Now log in with the same credentials (email and password). You make a request through your
AuthController at POST /auth/login:
You can also do the same via Postman/Bruno if you want, as shown before. You should get a
successful response with the generated token:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
You can copy the generated token and decode it at: [Link] You should see
something like:
{
"sub": "c6762198-c77d-4aab-a0b2-009ae1df94a7",
"role": "user",
"iat": 1759521269,
"exp": 1759524869
}
The JWT payload includes four key fields: sub is the unique identifier for the user, typically
idx_e6b0f1f9
their ID; role specifies the user's role, which is useful for access control; iat marks when the
token was issued; and exp defines when it will expire, ensuring tokens are short-lived for
security. These claims allow the server to authenticate requests and enforce permissions
effectively.
Note
In production, never hardcode the secret in your code. Instead, store it in an
environment variable (e.g., .env) and load it using @nestjs/config.
Here is an example:
[Link]({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: [Link]<string>('JWT_SECRET'),
signOptions: { expiresIn: '1h' },
}),
})
341 TypeScript in Action: Building Full Stack Applications
You can create a .env file in your devjobs-backend, and it should include the following:
JWT_SECRET=super-secret-key
This approach keeps sensitive data out of your source code and makes it easier to rotate keys
idx_4fcbcfac
when needed. We are able to generate a token, create, but at this point, we aren't using it for
anything yet. In the next section, we are going to look at how to protect routes.
In NestJS, guards determine whether a given request should be processed or not. The most
idx_e0ac82d8
common one is AuthGuard, which integrates directly with Passport (the authentication library
we installed earlier).
Since we already configured JWTs in our AuthModule, enabling route protection is
straightforward. All we need to do is use the @UseGuards(AuthGuard('jwt')) decorator.
Let's see a quick example:
@Controller('profile')
export class ProfileController {
@UseGuards(AuthGuard('jwt'))
@Get()
getProfile(@Request() req) {
return [Link]; // The decoded JWT payload
}
}
When a request includes a valid token (for example, Authorization: Bearer <token>),
idx_b60f0cca
NestJS verifies it using the configured JWT strategy. If the token is valid, the user payload
(decoded from the token) becomes available under [Link]. If it's missing or invalid, NestJS
automatically rejects the request with a 401 Unauthorized error.
Chapter 11 342
We'll apply this same approach to protect routes in our Companies and Jobs modules. But for
now, let's extend our in-memory store. Instead of using a simple array, we'll transition to
writing data to a JSON file, which ensures that we don't lose information every time the
application restarts.
whenever we restart the server: our data disappears. To fix this without introducing a full
database, we'll store our data in simple JSON files using [Link]'s built-in fs module.
backend app, create a new folder named common and add a file called [Link]:
constructor(filename: string) {
[Link] = join([Link](), 'data', filename);
}
private ensureFileExists() {
if (!existsSync([Link])) {
writeFileSync([Link], [Link]([], null, 2));
}
}
read(): T[] {
[Link]();
const content = readFileSync([Link], 'utf-8');
return [Link](content) as T[];
}
write(data: T[]) {
writeFileSync([Link], [Link](data, null, 2));
}
}
343 TypeScript in Action: Building Full Stack Applications
mkdir apps/devjobs-backend/data
This is where we'll store our .json files (for users, companies, and jobs). Next, let's update our
services starting with user services to use it.
@Injectable()
export class UsersService {
private storage = new FileStorage<any>('[Link]');
const newUser = {
id: randomUUID(),
name: [Link],
email: [Link],
password: hashedPassword,
role: [Link],
Chapter 11 344
companyId: [Link],
};
[Link](newUser);
[Link](users);
findByEmail(email: string) {
const users = [Link]();
return [Link]((u) => [Link] === email);
}
}
Now, each time we register a user, we load the current users from the file and add the user, and
idx_df8b83f4
then save the updated list. Your user data will persist between runs. You can restart your server
and still log in with the same credentials; there is no need to recreate users each time. With
user persistence in place, let's build out our Companies module.
Companies module
Companies represent employers who can post jobs. We'll allow anyone to view companies, but
idx_a2958559 idx_3b114998
only authenticated users with the company role can create them.
@Injectable()
export class CompaniesService {
private storage = new FileStorage<Company>('[Link]');
findAll(): Company[] {
return [Link]();
}
@Controller('companies')
export class CompaniesController {
constructor(private readonly companiesService: CompaniesService) {}
Chapter 11 346
@Get()
findAll() {
// Public route: returns all company profiles
return [Link]();
}
@UseGuards(AuthGuard('jwt'))
@Post()
create(@Body() body, @Request() req) {
// Protected route: only users with 'company' role can create companies
if ([Link] !== 'company') {
return { error: 'Only company accounts can create companies.' };
}
return [Link](body);
}
}
Jobs module
The Jobs module lets companies post and manage job listings, while job seekers can browse
idx_9880ba75 idx_55005e1b
@Injectable()
export class JobsService {
private storage = new FileStorage<Job>("[Link]");
• findAll(filters?): Reads all job postings and filters them by query params (such as
location or technology)
• create(): Generates a unique ID, adds the job post, and saves the updated jobs list to
[Link]
import { Controller, Get, Post, Body, Query, UseGuards, Request } from '@nestjs/
common';
import { JobsService } from './[Link]';
import { AuthGuard } from '@nestjs/passport';
@Controller('jobs')
export class JobsController {
constructor(private readonly jobsService: JobsService) {}
@Get()
findAll(@Query() query) {
return [Link](query);
}
@UseGuards(AuthGuard('jwt'))
@Post()
create(@Body() body, @Request() req) {
if ([Link] !== 'company') {
return { error: 'Only company users can post jobs.' };
}
return [Link]({ ...body, companyId: [Link] });
}
}
With that in place, we can now test the full flow of our backend application in the next
idx_45174fe7
subsection.
1. Register a new user with the company role. See the following sample request:
"role": "company",
"companyId": "ffdba55c-78c2-44e3-9665-44d0582c1051"
}'
2. Log in and copy the JWT token from the response. See the following sample request:
5. Post a job under that company with a POST request to /jobs. See the following sample
request:
6. You can retrieve the jobs using GET /jobs with query parameters such as ? idx_f905bef0
tech=typescript&location=remote:
Congratulations! We just went through the flow of our DevJobs backend. Our backend
idx_4aafedf5
consumes the API. I have opted to use React to keep things relatively straightforward, but the
ideas and concepts learned here can be applied elsewhere. We are going to reuse the types we
generated with Orval for our API contracts in the previous section while building.
We already used Orval to generate types for the backend. Now we'll configure it to generate
React Query hooks that give us type-safe API calls with zero manual work.
idx_979f50c2
[Link] will be the root component where we'll set up routing, and [Link] is the entry point
where we'll configure React Query.
Before we proceed, let's install some frontend dependencies that we will need for our
application.
351 TypeScript in Action: Building Full Stack Applications
use. This instance will handle all our HTTP requests and is where we'll add authentication
tokens.
First, if you haven't already, create the api-client library structure:
mkdir -p libs/api-client/src
• Creates an Axios instance and uses /api as the base URL. All requests will be made
relative to /api, for example, to retrieve jobs, GET /api/jobs, and to log in, POST /api/
auth/login.
• Adds an interceptor that automatically reads the JWT from localStorage and
attaches it as a Bearer token in the Authorization header on every request, so we
don't have to manually configure authentication in each component.
• Exports a custom API function that Orval uses as its mutator for all generated
requests. It unwraps the Axios response and returns only [Link], keeping our
generated hooks simple and consistent.
Note
In this example, we store the JWT in localStorage for simplicity. In real-world
applications, token storage is a security trade-off. While localStorage is easy to use, it
can be vulnerable to XSS attacks if malicious scripts gain access to the page. Many
production systems instead use secure, HTTP-only cookies to reduce this risk. The right
choice depends on your application's security requirements and architecture.
Why create this now? Orval's configuration references this file as its mutator. When Orval
idx_5f2cde03
generates hooks, it wraps all API calls with our api function, ensuring that requests
automatically use the /api base path and include the Authorization: Bearer <token>
header when available.
353 TypeScript in Action: Building Full Stack Applications
hooks. Open [Link] at the root of your monorepo and update it:
export default {
devjobs: {
input: './apps/devjobs-backend/src/[Link]',
output: {
target: 'libs/api-client/src/generated', // folder for generated files
client: 'react-query',
mode: 'tags', // separate file per tag
override: {
mutator: {
path: './libs/api-client/src/[Link]',
name: 'api',
},
},
prettier: true,
index: true,
clean: true,
},
hooks: {
afterAllFilesWrite: [
() => {
[Link](' DevJobs API client generated successfully');
},
],
},
},
};
• input: Points to your OpenAPI specification, the same file that defines your backend
API contract
• mode: 'tags': Organizes generated files by OpenAPI tags (users, auth, jobs, and
companies). Each tag gets its own file, making the generated code more maintainable.
• clean: true: Clears previously generated files before regeneration, preventing stale
code.
• [Link]: Runs a small script after generation, in this case, logging a
confirmation message so we know the client was rebuilt successfully.
After running npx orval, you'll see new files:
libs/api-client/src/generated/:
These files would contain the relevant hooks needed to communicate our backend app for
example:
• [Link]: In this file, you will find hooks such as useGetJobs, usePostJobs, etc.
• [Link]: In this file, you will find hooks such as useGetCompanies,
usePostCompanies, and so on.
TContext
> => {
You'll use these hooks throughout your React components. There is no need to write fetch calls
idx_6bf41f36
Query's provider. This gives all components access to the query cache and configuration.
Update your apps/devjobs-frontend/src/[Link].
With everything in place, run Orval to generate your API hooks:
npx orval
Orval reads your OpenAPI spec and generates type-safe React Query hooks.
Update your apps/devjobs-frontend/src/[Link]:
[Link](
(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
) as [Link]
);
QueryClient is React Query's cache manager. Our configuration tells it the following:
idx_3129214e
caching, deduplication, background updates, and more. In the next step, we are going to start
building the authentication flow.
357 TypeScript in Action: Building Full Stack Applications
state globally. This simply means storing the state of our application in a place where every
component has direct access to it; the global state would handle storing the JWT token,
decoding it to get user information, and providing login/logout functionality to all
components. There are several options, such as Zustand, Redux, and Recoil, but for this project,
we are going to use React Context. This way, we don't have to install other libraries, and it just
works.
mkdir -p apps/devjobs-frontend/src/contexts
DecodedToken represents the payload structure of our JWT, while AuthContextType defines
what values and methods will be available to the rest of the app.
Chapter 11 358
With our context created, we can now create a provider component. This provider will hold our
main logic for authentication state, decoding, and persistence.
Let's start with the state and initialization:
The token state stores the JWT, while user stores the decoding info. We use useEffect to load
any saved token when the app first mounts.
Next, let's decode the token whenever it changes:
idx_2cccc575
useEffect(() => {
if (!token) {
setUser(null);
return;
}
try {
const decoded = jwtDecode<DecodedToken>(token);
logout();
return;
}
In the useEffect hook we just provided, all authentication-related side effects are
intentionally handled in one place, so state updates remain predictable and easy to reason
about. The jwtDecode library is used to decode the JWT token's payload without verifying its
signature, extracting key details such as the user ID (sub) and role. If no token is present, the
user state is immediately cleared to null. Upon successful decoding, the code optionally
validates expiration by checking whether the expiration claim, converted to milliseconds, is
idx_233696f5
earlier than the current timestamp; if expired, it logs a warning and triggers logout to clear the
token. Otherwise, it updates the user state with the decoded user ID and role. If any decoding
error occurs, such as an invalid token format, it logs the issue and initiates a logout to maintain
a safe authentication state.
For example, when a user logs in and a token is stored, this effect automatically runs, decodes
the token, and updates the UI state so components can react immediately—such as showing
admin controls for privileged users or redirecting unauthenticated users to a login page:
}, []);
login() saves the token to both localStorage and the React state. logout() removes
everything, effectively logging the user out. Finally, let's wrap up AuthProvider:
return (
<[Link]
value={{
token,
user,
login,
logout,
isAuthenticated: !!user && !!token,
}}
>
{children}
</[Link]>
);
Lastly, we'll add a simple helper for easier access to the context:
idx_67b3122d
For production apps, consider adding a refresh token mechanism that silently renews access
tokens when they expire. We won't implement that here, but it's a good next step once basic
idx_0a827221
authentication is working.
Lastly, make sure that in the [Link] file, you wrap the entire application with AuthProvider,
as follows:
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
// rest of you code below ...
This is needed to make the login functionality work. With that in place, let's move on to
building the login page.
Login page
In this section, we'll build the login page for our frontend application. Rather than writing
idx_c34842e0 idx_62db6e1c
everything at once, we'll build it incrementally, adding one small piece at a time so it's always
clear what changed and why.
We'll start simple, just rendering the page, then gradually add inputs, state, and form
handling, and finally, wire everything to the backend using our generated API hooks.
Step 1: Creating the login page and confirming that it renders
Let's start by creating the login page component. Create the following file:
idx_59b71061
apps/devjobs-frontend/src/pages/Login/[Link]:
For now, we'll keep it intentionally minimal and just render some placeholder text:
At this point, we're not adding any logic; we just want to confirm that the page renders
correctly.
Chapter 11 362
Note
We've removed the default boilerplate code that was generated when the frontend app
was created. For now, we're keeping things intentionally minimal.
If you visit the root route (/login) in the browser, you should now see the following:
Once you see this, you'll know that the page and routing are wired correctly.
idx_29ef6140
• A full-height wrapper
• A centered container
• A heading
363 TypeScript in Action: Building Full Stack Applications
There is no styling beyond layout and spacing. Update your Login component as follows:
idx_abba0cbf
At this point, we still don't have a form, just a visually structured page.
Step 4: Adding the form and input fields (still no state)
With the page shell in place, we can now add the login form itself. Add the following inside the
idx_bf56c4c0
<form>
<div className='flex flex-col space-y-1'>
<label htmlFor='email' className='text-sm'>
Email
</label>
<input
id='email'
type='email'
className='border px-3 py-2 rounded'
/>
</div>
At this stage, the form renders correctly and the inputs accept text, but nothing happens when
idx_715211e0
Next, we make the inputs controlled by updating each input to reflect and update the state, as
follows:
<input
id='email'
type='email'
className='border px-3 py-2 rounded'
onChange={(e) => setEmail([Link])}/>
<input
id='password'
type='password'
className='border px-3 py-2 rounded'
onChange={(e) => setPassword([Link])}/>
Now, whenever the user types, React updates the component state, and the input value always
idx_1be2cabf
component:
Now, when you type an email address and password, click Login, and check the browser
idx_3052b62f
This confirms that the form submits correctly and we have access to the user's input.
You may get an error with imports, as shown in the following figure:
Chapter 11 366
Check two main things. First, confirm that your alias exists in [Link]:
{
"compilerOptions": {
{ "baseUrl": ".",
"paths": {
"@devjobs/api-client": ["libs/api-client/[Link]"],
"@devjobs/api-client/*": ["libs/api-client/src/*"]
}
}
}
This hook was generated by Orval and internally uses React Query's useMutation to call our /
idx_695f9674 idx_b97090a2
auth/login endpoint.
login([Link].access_token);
};
message to the user. The React Query (TanStack query) mutation hook exposes an IsError
Boolean, which lets us know if something went wrong. We can use this Boolean to
conditionally render an error message, as follows:
{[Link] && (
<p className='text-sm'>
Login failed. Please check your email and password.
</p>
)}
The preceding code simply checks whether [Link] is true, and if it is, it uses
idx_328f7e2a
the logical AND operation (&&) to render the error message (within the p tag). If everything goes
well, isError is false and we don't see an error message.
Beyond just displaying the message, you might want to do more in case of errors, such as
logging the error or sending some custom message to an error monitoring tool that the devs or
operation team monitors. To do this, as we discussed in previous chapters when we talked
about error handling, you can introduce a try/catch block into the handle submit, as follows:
login([Link].access_token);
} catch (error) {
// you can use your custom loggers here to handle the error in a better
way
[Link]('Login failed:', error); //
}
};
Now that we've seen how an error is handled, let's log in successfully.
idx_2015f53d
369 TypeScript in Action: Building Full Stack Applications
Step 10: Disabling the login button while the request is in progress
When a login request is in flight, the user can click the Login button multiple times. This can
idx_b137f144
cause double submissions and multiple API calls, which may lead to confusing UI behavior (for
example, multiple redirects or repeated error messages). React Query exposes an isPending
flag on the mutation that tells us when the request is still running. We can use it to disable the
button and optionally show a loading label.
Update your submit button like this:
<button
type="submit"
disabled={[Link]} >
</button>
Now, while the request is in progress, the button becomes disabled, and the user sees
idx_5022b346
// ch10/devjobs/apps/devjobs-frontend/src/app/Jobs/[Link]
export function Jobs() {
return <div>Jobs Page</div>;
}
//
export function App() {
return (
<div>
<Routes>
<Route path='/login' element={<Login />} />
<Route path='/jobs' element={<Jobs />} />
</Routes>
Chapter 11 370
</div>
);
}
Now we return our Login component and update it so that after a successful login, it navigates
to the jobs page.
To make the navigation work, we make use of the hook from react-router-dom.
idx_cf79c0ba
First, as before, we import the hook at the top of our Login file:
Then, we instantiate the hook and assign it to a navigate variable within the Login
component, as follows:
Now, inside our handleSubmit function, just under the point after the login, we call the
navigate function to redirect to the jobs page:
Now, when you log in successfully, it should navigate to the jobs page. This is a great step, but
anyone can access the /jobs routes without logging in to the application first.
idx_ff34120a
Protected routes
In this step, we are going to create a protected route wrapper so that unauthenticated users
idx_2a879957
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
ProtectedRoute is a small wrapper component that controls access to certain pages. It checks
idx_c7813ec0 idx_971cb07f
To use the component we just created to protect our jobs route, we update our [Link] as
idx_632feccf
follows:
<Route
path="/jobs"
element={
<ProtectedRoute>
<Jobs />
</ProtectedRoute>
}
/>
In the preceding snippet, we are simply wrapping our Jobs component with ProtectedRoute,
and using the Navigate component to enforce redirects from the home route to the /jobs
route.
To test this, you can either clear the token from local storage or copy and paste the route for the
job into an incognito browser, and you should be redirected to the login page every time.
</div>
);
}
This matches the same minimal Tailwind approach we used for the login.
idx_6ab76779
if ([Link]) {
return <div className="p-4">Loading jobs...</div>;
}
if ([Link]) {
return <div className="p-4">Failed to load jobs.</div>;
}
<ul className='space-y-2'>
{[Link]((job) => (
<li key={[Link]} className='border rounded p-3'>
<div className='font-medium'>{[Link]}</div>
Chapter 11 374
In the previous code, we loop through the fetched jobs and display a clean and minimalist list,
which shows each job's title, location, and a job description.
Summary
In this chapter, we applied many of the core concepts covered earlier to building a full stack
application in a more realistic, professional setting. We adopted a contract-first approach,
using an OpenAPI specification as a shared source of truth between the frontend and backend.
From that contract, we generated a fully typed client and used it to power our React frontend,
ensuring strong type safety across the entire stack. On the backend, we built out the necessary
endpoints using NestJS, implementing authentication, protected routes, and job listing
functionality.
On the frontend, we intentionally kept the UI minimal. In a production application, you would
likely add input validation, richer error handling, pagination, filtering, and more refined UI
states. However, the goal of this chapter was not visual polish; it was to demonstrate how the
core pieces fit together: contract-driven development, type safety, authentication, route
protection, and data fetching with React Query.
We now have a functioning full stack flow:
• Users can log in
• The authentication state is persisted
• Protected routes restrict access
• Authenticated users can fetch and view data
More importantly, we've established a way of thinking—one that prioritizes shared contracts,
predictable architecture, and type-driven development across the stack.
In the next chapter, we'll take this foundation further by building an AI-backed full stack
feature, exploring how to integrate a Large Language Model (LLM)-based chatbot into your
applications in a clean, structured, and production-aware way.
375 TypeScript in Action: Building Full Stack Applications
Note: Have your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
12
TypeScript in Evolving Systems
Up to this point in the book, you've learned about the individual tools that make TypeScript
effective in production applications. You've seen why late failures are expensive, why moving
errors earlier matters, and how types can do more than annotate values. They can express
intent, enforce structure, and shape how a system evolves. You've also seen these ideas applied
in a clean, contract-first workflow where frontend and backend move together around shared
definitions.
That approach works best when requirements are well understood early and teams evolve the
system in a coordinated way.
This chapter explores how those same principles apply when requirements change, systems
grow, and assumptions begin to shift over time. In this story, the system already works. A proof
of concept has shipped. Users are happy. Then requirements change, more developers join, and
assumptions begin to drift. That's when mistakes become easy to make, and expensive to
discover, because the system feels "stable" right up until the moment it breaks in production-
like conditions.
So, instead of introducing a new framework or a new syntax feature, this chapter follows one
concrete narrative: a small full stack support chatbot that evolves under real-world pressure.
We will intentionally experience runtime failures first, then progressively introduce TypeScript
techniques that move those failures earlier from the browser and production to build time,
where they are cheaper and easier to fix.
We will cover the following main topics:
• Starting with a fast proof of concept
• Detecting and handling contract drift
• Making the contract explicit
Chapter 12 378
Technical requirements
All code for this chapter is available as a runnable Nx workspace, and each major milestone is
tagged so you can jump to the exact state being discussed. You can download the example
project and code for this book by following the instructions in the Download the example code
files section in the Preface of this book.
This chapter's code files are included in the downloadable code bundle.
You'll see tags such as ch12-01-poc, ch12-02-contract-drift, and so on. Don't treat these
tags as "versions you should copy." Treat them as snapshots in a story. The point is to feel how
each small improvement changes what the system makes easy and what it makes difficult.
browser, see the conversation history, and receive responses from a backend API. The frontend
sends conversation history to a single endpoint, POST /api/chat, and the backend replies with
a short answer.
At this stage, speed matters more than correctness. You don't design a perfect contract on day
one. You let assumptions live in code because early momentum matters, and the goal is
learning what users actually want.
On the backend, the handler reads untyped JSON and returns a simple response:
[Link]({
reply: `You said: ${last?.content ?? ''}`,
});
});
379 TypeScript in Evolving Systems
On the frontend, the response is treated as "whatever comes back," and messages are stored as
any[]:
The chatbot works. The demo succeeds. If you check out the ch12-01-poc tag and run the app,
everything feels fine. But pause for a moment and ask yourself a subtle question: where is the
contract for this endpoint written down?
idx_015d1b3e
string." It must become a structured object containing the assistant's message, a list of
idx_1e4c9675
{
"reply": {
"message": { "role": "assistant", "content": "You said: hello" },
"references": [],
"followUps": []
}
}
Before opening the browser, you build the frontend. The build passes.
This is the most dangerous part of the story. A passing build creates a false sense of safety. It
feels like TypeScript "approved" the change. But TypeScript did not approve it. It simply had
Chapter 12 380
nothing meaningful to check, because the frontend opted out of typing at the boundary. any[]
and untyped [Link]() leave the compiler with nothing to enforce.
When you refresh the UI and send a message, React throws a runtime error because it tries to
render an object where it expected a string. This is the exact failure mode we've been warning
about: a mistake that should have been caught immediately instead surfaces late, in the
browser, under production-like conditions.
If you check ch12-02-contract-drift, you can reproduce this failure yourself. The build
succeeds. The runtime explodes.
That experience is intentional. You need to feel why this class of bug is so painful. It isn't just
that it breaks. It's that it breaks after you have already been told "everything is fine."
In earlier chapters, you saw a contract-first workflow where a formal contract is defined up
front (often with OpenAPI) and types are generated. In this chapter, we are intentionally doing
the opposite, starting with implicit assumptions, because that is how many real systems begin.
The goal now is to claw our way back to safety without pretending we had perfect planning on
day one.
So, we introduce a shared TypeScript library that defines the shapes of our chat messages,
requests, and responses. Even if you eventually generate these types from OpenAPI, the idea is
the same: make the contract real code, shared across the boundary.
Create a shared module—for example, libs/chat-contract—and define a minimal domain
contract:
url: string;
}
Notice what we're doing here. We are not trying to model a vendor SDK. We are not trying to
mirror raw OpenAI, Anthropic, or any other provider response. We are describing our domain:
what our product needs to show a user. That distinction matters more as the system grows.
Now, both frontend and backend import these types. At this moment, the contract stops being
idx_0ec6346c
When you call the API, you stop treating JSON as "whatever comes back." You explicitly type
the call:
Even this simple shift changes the developer experience immediately. If you try to treat reply
as a string now, TypeScript stops you during build. The same mistake that previously slipped
through now fails before you even refresh the browser.
That is the first rescue moment. It is not "advanced TypeScript." It is basic TypeScript used at
the correct boundary.
If you check ch12-03-shared-contract and repeat the earlier mistake, TypeScript blocks the
build.
Then add type guards for your domain objects. You can keep this simple at first and deepen
them as the contract grows:
return (
(role === 'user' || role === 'assistant') &&
typeof [Link] === 'string'
);
}
return true;
}
Now, instead of trusting JSON, you treat it as unknown at the boundary. On the backend, that
means validating [Link] before using it:
idx_98941660
if (!isChatRequest(body)) {
[Link](400).json({ error: 'Invalid request body' });
return;
}
Chapter 12 384
[Link](response);
});
if (!isChatResponse(data)) {
throw new Error('Server returned an invalid ChatResponse shape');
}
return data;
}
This is not about building a heavyweight validation framework. It's about making the
boundary honest. Once the data passes the gate, the rest of the system can rely on strong types.
idx_e068465c
If it fails the gate, you fail fast with a clear error instead of letting corrupted data quietly poison
your UI.
This corresponds to the ch12-04-runtime-gate tag.
385 TypeScript in Evolving Systems
Each provider adapts its SDK internally. This is where design patterns stop being academic and
start being practical. The provider is effectively a strategy (we can swap implementations), and
the provider wrapper is often an adapter (it converts vendor output into your domain shape).
Your core application depends only on the interface, not on vendor-specific JSON.
A factory picks the provider at runtime:
Now the backend handler can remain stable. It calls [Link]() and returns
the domain response. Providers can evolve without forcing a coordinated frontend rewrite.
Chapter 12 386
That is what "stable boundaries" looks like in practice: you isolate volatility behind an
interface.
Now, a response is always one of two valid shapes, and the kind field is the discriminator that
makes narrowing natural. On the frontend, you stop guessing. You switch on kind:
switch ([Link]) {
case 'answer':
// render reply only
break;
case 'answer-with-citations':
renderCitations([Link]);
break;
default: {
const _exhaustive: never = response;
return _exhaustive;
387 TypeScript in Evolving Systems
}
}
That never line looks small, but it changes how the system behaves under change. If you add a
third variant later, say kind: 'rate-limited', and forget to handle it, the build fails.
Correctness is enforced structurally.
This is what "type safety under change" actually means. It doesn't mean you never ship bugs.
idx_51e95606
It means entire classes of bugs become difficult to ship because the type system forces you to
update every place that must change.
Why the never check works: If a new variant is added to the union and the switch isn't
updated, TypeScript detects that not all cases are handled. In that situation, the remaining
unhandled type flows into the default branch, causing the assignment to never fail at compile
time. This intentional error forces you to explicitly handle the new case, preventing incomplete
logic from compiling.
shortcut appears: repeated as SomeType casts sprinkled across your API calls. Those casts look
like type safety, but they silently disable it, because you are telling TypeScript, "Trust me," at
the exact boundary where trust is most dangerous.
A better pattern is to define a mapping between endpoints and their types, and then build a
small generic client where the endpoint literal determines the request and response types.
Here is one lightweight approach:
type ApiRoutes = {
'/api/chat': {
req: ChatRequest;
res: ChatResponse;
};
'/api/health': {
req: undefined;
res: { ok: true };
};
};
Chapter 12 388
With this pattern, you don't get to accidentally call the wrong endpoint and pretend it returns
the shape you wanted. The mapping determines the type. The literal determines the type. The
idx_574cd2ba
Summary
In this chapter, you followed the lifecycle of a TypeScript system after its initial success. You
began with a fast proof of concept where frontend and backend matched only by assumption.
When the backend response evolved, the frontend broke at runtime because TypeScript had
nothing concrete to enforce.
389 TypeScript in Evolving Systems
By introducing shared contracts, you transformed implicit agreements into explicit code. By
adding a small runtime gate at the JSON boundary, you acknowledged that network data is
unknown until validated, and prevented corrupted shapes from leaking into your system. As
collaboration and complexity increased, you applied patterns to create stable boundaries and
advanced TypeScript features to make valid states explicit. Provider interfaces prevented
vendor details from leaking across layers. Discriminated unions replaced fragile optional fields
and forced exhaustive handling as the contract evolved. Typed API clients eliminated unsafe
casts and removed an entire category of invisible bugs.
TypeScript helped not because it was "advanced," but because it turned easy-to-make
mistakes into hard-to-ship mistakes. That is the role TypeScript plays in long-lived systems:
not as a set of annotations, but as a safety mechanism that evolves alongside your code as
requirements change.
Note: Have your invoice handy. Purchases made directly from the Packt website don't require an
invoice.
13
Unlock Your Exclusive Benefits
Your copy of this book includes the following exclusive benefits:
Follow the guide below to unlock them. The process takes only a few minutes and needs to be
completed once.
Chapter 13 392
Note
Note: If you bought this book directly from Packt, no invoice is required. After Step 2,
you can access your exclusive content right away.
Step 2
Scan the QR code or go to [Link]/unlock.
On the page that opens (similar to Figure 13.1 on desktop), search for this book by name and
select the correct edition.
393 Unlock Your Exclusive Benefits
Step 3
After selecting your book, sign in to your Packt account or create one for free. Then upload your
invoice (PDF, PNG, or JPG, up to 10 MB). Follow the on-screen instructions to finish the
process.
Need Help
If you get stuck and need help, visit [Link] for a
detailed FAQ on how to find your invoices and more. This QR code will take you to the help
page.
Note
Note: If you are still facing issues, reach out to customercare@[Link].
[Link]
Subscribe to our online digital library for full access to over 7,000 books and videos, as well as
industry leading tools to help you plan your personal development and advance your career.
For more information, please visit our website.
Why subscribe?
• Spend less time learning and more time coding with practical eBooks and Videos from
over 4,000 industry professionals
• Improve your learning with Skill Plans built especially for you
• Get a free eBook or video every month
• Fully searchable for easy access to vital information
• Copy and paste, print, and bookmark content
At [Link], you can also read a collection of free technical articles, sign up for a
range of free newsletters, and receive exclusive discounts and offers on Packt books and
eBooks.
Other Books You May Enjoy
If you enjoyed this book, you may be interested in these other books by Packt:
Mastering TypeScript
Nathan Rozentals
ISBN: 9781800564732
• Gain insights into core and advanced TypeScript language features
• Integrate with existing JavaScript libraries and third-party frameworks
• Build full working applications using JavaScript frameworks, such as Angular, React,
Vue, and more
• Create test suites for your application with Jest and Selenium
• Apply industry-standard design patterns to build modular code
• Develop web server solutions using NodeJS and Express
TypeScript 5 Design Patterns and Best Practices
Theofanis Despoudis
ISBN: 9781835883228
• Understand the principles of design patterns and their role in TypeScript development
• Learn essential patterns, including creational, structural, and behavioral, with
TypeScript
• Differentiate between patterns and design concepts and apply them effectively
• Gain hands-on experience implementing patterns in real-world TypeScript projects
• Explore advanced techniques from functional and reactive programming paradigms
• Write efficient, high-quality TypeScript code that enhances performance and flexibility
Packt is searching for authors like you
If you're interested in becoming an author for Packt, please visit [Link] and apply
today. We have worked with thousands of developers and tech professionals, just like you, to
help them share their insight with the global tech community. You can make a general
application, apply for a specific hot topic that we are recruiting an author for, or submit your
own idea.
[Link]
Your review is important to us and the tech community and will help us make sure we're
delivering excellent quality content.
Index
asynchronous error handling with 167, 168
A benefits 168
asynchronous errors 157, 164
API client file 324
error handling best practices 169
API response caching 210
handling 164, 165
Abstract Factory pattern 221 handling, with async/await 167, 168
concrete factories, implementing 222 handling, with promises 166, 167
concrete products, creating for each 221 strategies for handling 165
theme
asynchronous operations
factory (client code), using 223
optimizing 214, 215
interface, defining 222
product interfaces, defining 221 authentication flow, client-side TypeScript
related objects, creating 221 auth context, creating 357 – 361
building 357
Adapter pattern 228
jobs page, building 372
adapter, creating 229
login page 361
adapter, using 230
protected routes 371, 372
incompatible interfaces, making 228
compatible authentication, server-side 334
TypeScript
new system 229
adding, to backend 334
old system 229
Auth module, configuring 336
Architecture Decision Records (ADRs) Auth module, scaffolding 335
significance 296 AuthController service 338
used, for documenting architecture 295 authentication logic, adding to 337, 338
decisions AuthService class
writing 295 flow testing 339 – 341
Arrange-Act-Assert (AAA) pattern 143 packages, installing with 334
access modifiers 76 relevant packages, importing 335
example 76 symmetric, versus asymmetric JWT 336
advanced types 265
signing
conditional types 268 automated integration testing 130
generics 21 advantages 130
index signatures 268 disadvantages 131
intersection types 20, 21, 266
literal types 266 B
mapped types 267
Browser DevTools 170
mastering 19
nullable types 267 Builder pattern 223
type aliases 266 build method, using 225
union types 19, 20, 265 builder (client code), using 225
Builder class, creating 224
assertion 115
complex object, defining 224
async/await syntax complex objects, constructing 223
Index 400
D design patterns
adavantages
218
254
DRY principle 93 applying 218
Data Transfer Objects (DTOs) 319 behavioral patterns 237
Database Connection Manager 226 best practices, in TypeScript 256 – 258
complexity 255
Decorator pattern 232
base interface and component, defining 233 creational patterns 219
decorators, creating 233 disadvantages 255
decorators, stacking 234 practical examples 247
selecting 253, 254
DefinitelyTyped 4 structural patterns 228
DevJobs Platform
dev-jobs backend
specification 291 running 307
DevJobs code
development dependencies 98
repository strategy, selecting 296 – 298
driver 127
Don't Repeat Yourself (DRY) 135
data sanitization
example
182
182
E
techniques 182 ECMAScript Modules (ESM) 121
database connections 226 ES6 Modules 95, 96
Index 402
Jest 118
H Jobs module 346
Husky 309 [Link], adding 348
initializing 311 [Link] 347
setting up 310 jobs page, client-side TypeScript
home theater example 235 building 372
jobs list, rendering 373
I jobs, fetching with useGetJobs
loading and error states, adding
373
373
Immediately Invoked Function 65 simple jobs page shell 372, 373
Expression (IIFE)
Interaction to Next Paint (INP) 197
L
Iterator pattern 245
collection, creating 246 Lerna 299
collection, traversing 246, 247 lazy loading
interface 245 implementation, in React 207
iterator logic, creating 245 leafs 231
incremental approach 126 line breakpoints 177
bottom-up approach 127, 128 lint-staged 309
sandwich/hybrid approach 128 setting up 310
top-down approach 127 literal 59
index signatures 268 literal types 266
inheritance 66 logging services 226
classical inheritance 66
logical errors 154
classical inheritance, versus prototypal 68
examples 154 – 156
inheritance
identifying 155
prototypal inheritance 66 – 68
resolving 155
input validation 181
login page, client-side TypeScript
best practices 181
adding, to router 362
client-side validation 181
basic page shell, adding 362, 363
server-side validation 181
building 361
integration testing 125 creating 361
automated integration testing 130 – 132 form and input fields, adding 363, 364
big bang approach 126 form submission, handling 365, 366
incremental approach 126 friendly error message, displaying 368
manual integration testing 129, 130 input values, tracking with state 364, 365
need for 125, 126 login API, calling 367
practical example 132 – 139 login button, disabling while 369, 370
interfaces 79 request in progress
versus classes 79 token, saving with AuthContext 367
intersection types 266 token, storing 367
logpoints 177
J loops
JSON boundary optimizing 211, 212
protecting, with runtime gate 382 – 384 versus recursive functions 213
JavaScript 213
JavaScript bundle 198
Index 404
dependency managers 98
M [Link] TypeScript project
Vitest, setting up 119 – 124
Mocha 118
[Link] performance hooks 201
manual integration testing 129
advantages 129 NonNullable utility type 285
disadvantages 129, 130 Nx 298
usage scenarios 130 commands and workflows 308, 309
mapped types 267, 272 comparing, with alternatives 299, 300
advantages 276 plugins, installing 303, 304
best practices 276 significance, for TypeScript projects 299
built-in mapped types 275 Nx workspace
examples 273, 274 apps/ and libs/ convention, establishing 302
working 272 creation and configuration 300, 301
memoization 210 projects, generating and organizing 302
setting up 300
memory leaks
detecting 203 – 205 structure and configuration 301, 302
non-technical constraints 294
method decorators 270
npm (Node Package Manager) 99
method overriding 78
nullable types 267
mock 115
imported modules 117, 118
manual mocks 116 O
module [Link]() method 72
defining 92 Observer pattern
module systems 91 concrete observers, creating 239
clarity 91 implementing 239
CommonJS 96 observer interface, defining 237
encapsulation 91 – 93 subject, implementing 238
ES6 modules 95, 96 weather station example 237
improved testing 94 Omit utility type 284
maintainability 94
OpenAPI documentation 320
reusability 91 – 94
OpenAPI spec file 324
scalability 94
scope management 92, 93 Orval 323
config, adding 323, 324
module-based architecture 87
installation 323, 324
monolithic repository (monorepo) 296
object-oriented programming 57, 58
benefits 297
(OOP)
versus polyrepo 297, 298 static methods and properties, in 62, 63
multiple endpoints TypeScript
without fragile casts 387, 388 TypeScript classes 60 – 62
TypeScript classes syntax 64, 65
N TypeScript objects 58
NestJS module 328 objects in TypeScript 58
cake object example 59
[Link] application
frontend, generating 304 – 306 literals 59
[Link] plugin 303
[Link]
405 Index
Single Responsibility Principle 33, 36 Adapter for third-party APIs 249, 250
(SRP) Adapter pattern 228
applying, to functions 36 – 38 Composite pattern 230
Singleton pattern 226 Decorator pattern 232
private constructor, using 226 Facade pattern 234
static accessor method, using 227 practical examples 249, 250
verifying 227, 228 stub 127
Strategy pattern 240 synchronous errors 157 – 160
concrete strategies, implementing 240 handling 157
context, creating 241 input data, validating 162, 163
interface, defining 240 issue, fixing with try...catch 160, 161
strategies, switching at runtime 241 syntax errors 148 – 150
Supertest 132 system scaling
sandwich/hybrid approach 128 without leaking vendor details 385
advantages 128
disadvantage 128 T
security 180
Test-Driven Development 111, 140, 155
security best practices 180 (TDD)
data sanitization 182 best practices 140 – 143
error handling 184, 185 Green 140
input validation 181 Red 140
secure coding techniques 183, 184 Refactor 140
sensitive data, managing 185, 186 Turborepo 299
server-side TypeScript, with [Link] 320 TypeDoc
authentication, adding to backend 334 integrating, for comprehensive 42 – 49
business logic, adding to UserService 330 TypeScript documentation
companies module 344
TypeScript 1, 125, 158, 213
contract-first approach 320 advanced types 19
devjobs backend full flow, testing 348 – 350 advantages 3
feature modules, adding 326 – 328 basic types 9
file-based persistence, adding 342 basic types, using 10, 11
first endpoint, creating 329 class, creating 60 – 62
Jobs module 346 classes 60
JWT guards, adding to protect routes 341 complex types 14
request, making to newly created 332, 333 conditional types 26, 27
endpoint features 3
users module 328, 329 installation 5
serverless deployment 294 objects 58
setters 75, 76 project setup 5–9
simple factory 220 real-world applications 4
slow function
syntax, for creating classes 64, 65
optimizing, by avoiding repeated work 202 type inference 13, 14
source maps 170 TypeScript backend 201
working with 171, 172 TypeScript functions, side effects 39
static properties and methods, 62, 63
avoiding 41
TypeScript concurrency example 39
key concerns 40
structural patterns 228
407 Index