50 Ts
50 Ts
Azat Mardan
This book is available at http://leanpub.com/50-ts
This is a Leanpub book. Leanpub empowers authors and publishers with the Lean Publishing
process. Lean Publishing is the act of publishing an in-progress ebook using lightweight tools and
many iterations to get reader feedback, pivot until you have the right book and build traction once
you do.
Welcome . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
7. Outro . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 258
Welcome
Hi there, I’m Azat MARDAN, your tour guide on this merry adventure of TypeScript faux pas. If
you’re wondering who the heck I am and why you should trust me, that’s a fantastic question.
I’m the author of the best-selling books Pro Express.js, Full Stack JavaScript, Practical Node.js and
React Quickly. For those who are not in the habit of browsing bookstores, those are indeed about
JavaScript, Node.js, and React, not TypeScript. But don’t let that lead you into a false sense of
security. I’ve seen enough TypeScript in the wild during my tech stints at Google, YouTube, Indeed,
DocuSign and Capital One to fill an ocean with semicolons. Or maybe more accurately, to forget to
fill an ocean with semicolons… but more on that later.
If you’re still wondering, “Well, Azat, how did you manage to master yet another web technology
to the point of writing a book about it?” I’ll let you in on my secret. The secret is, I make a lot of
mistakes. An impressive amount, really. Enough to write a book about them. And every mistake,
from the tiniest comma misplacement to the catastrophic data type mismatches, has added a new
layer of depth to my understanding of the JavaScript and TypeScript ecosystem. One might think
after writing code at such high-profile companies like Google and Amazon, I’d be too embarrassed
to publicly document the many ways I’ve goofed up. But you see, dear reader, I believe in the power
of failure as a learning tool. Therefore, this book is an homage to my countless mistakes and the
invaluable lessons they’ve taught me.
To be clear, I wrote “50 TypeScript F*ck Ups and How to Avoid Them” not because I like pointing
out people’s mistakes, but because I wanted to help you avoid the same pitfalls I encountered when
I was in your shoes. I also wanted to reassure you that making mistakes is just a part of the learning
process. Every single typo, missed semicolon, and misuse of a null vs undefined (yes, they are
different, very different) is a step toward becoming a TypeScript maestro.
In this book, we’ll confront those mistakes head-on, dissect them, learn from them, and hopefully
have a few laughs along the way. And don’t worry, I’ve committed most of these blunders at least
once, some of them probably twice, and in rare embarrassing cases, three times or more!
So, whether you’re a TypeScript greenhorn or a seasoned code gunslinger, get your code editors
ready, grab a cup of your strongest coffee, and prepare to embark on a journey through the
treacherous terrain of TypeScript that is hopefully as enlightening as it is entertaining. And
remember that TypeScript developers never get lost because they always have a map()! :-) So, here’s
to a hundred mistakes that you’ll never make again. Without further ado, let’s embark on this
adventure that we’ll call TypeScript. Happy reading and happy coding!
Cheers,
Professor Azat MARDAN,
Distinguished Software Engineer
Microsoft Most Valuable Professional,
Author of React Quickly, Practical Node.js, Pro Express.js and Full Stack JavaScript
1. Why TypeScript and Why This Book
This chapter covers
Did you open this book expecting to immediately delve into the TypeScript guide and TS mistakes
to avoid? Surprise! You’ve already stumbled onto the first mistake—-underestimating the enter-
tainment value of an introduction. Here you thought I’d just drone on about how you’re holding
in your hands the quintessential guide to TypeScript and its pitfalls. That’s half correct. The other
half? Well, let’s just say I wrote the introduction while sipping my third cup of coffee, so hold onto
your hats because we’re going on a magical carpet ride through the benefits that this book provides
and touch upon how this book can help you, before we arrive at TypeScript land.
Navigating the world of TypeScript can be a challenging and yet a rewarding journey at the
same time. As you delve deeper into TypeScript, you’ll quickly discover its power and flexibility.
However, along the way, you may also stumble upon common pitfalls and make mistakes that could
hinder your progress. This is where this book comes in, serving as your trusty companion and guide
to help you avoid these obstacles and unlock the full potential of TypeScript.
Here’s a little programmer humor to lighten the mood: Why did the developer go broke? Because
he used up all his cache. Just like that joke, TypeScript can catch you off guard.
Consider the following as the key benefits you will gain from this book:
• Enhance your understanding of TypeScript: By studying the common mistakes, you’ll gain
a deeper insight into TypeScript’s inner workings and principles. This knowledge will allow
you to write cleaner, more efficient, and more maintainable code.
• Improve code quality: Learning from the mistakes covered in this book will enable you to
spot potential issues in your code early on, leading to a higher quality codebase. This will not
only make your applications more robust but also save you time and effort in debugging and
troubleshooting.
• Boost productivity: By avoiding common mistakes, you can accelerate your development
process and spend more time building features and improving your application, rather than
fixing errors and dealing with technical debt.
• Strengthen collaboration: Understanding and avoiding these mistakes will make it easier for
you to work with other TypeScript developers. You’ll be able to communicate more effectively
and collaborate on projects with a shared understanding of best practices and potential pitfalls.
1. Why TypeScript and Why This Book 3
• Future-proof your skills: As TypeScript continues to evolve and gain popularity, mastering
these concepts will help you stay relevant and in-demand in the job market.
Maybe you’ve tried mastering TypeScript before and didn’t quite get there. It’s not your fault. Even
for me some TypeScript errors are perplexing and the reasoning behind them (or a lack of thereof)
confusing. I suspect the authors of TypeScript intentionally made the error messages so cryptic as
to not allow too many outsiders to enlighten in the mastery of types.
And TypeScript is a beast, it’s powerful and its features are vast! Learning TypeScript deserves
reading a book or two to get a grasp on it and then months or years of practice to gain the full
benefits of its all features and utilities. However, as software engineers and web developers, we
don’t have a choice not to become proficient in TypeScript. It’s so ubiquitous and became a de facto
standard for all JavaScript-base code.
All in all, we must learn TypeScript, because if we don’t do it, it’s easy to fall back to just old familiar
JavaScript that would cause the same familiar and painful issues like type-mismatch, wrong function
arguments, wrong object structure and so on. Speaking of old JavaScript code, let’s see why we even
should bother with TypeScript.
• Static typing: TypeScript introduces static typing to JavaScript, which helps catch errors early
in the development process. By providing type information, TypeScript enables developers
to spot potential issues before they become runtime errors. This leads to more reliable and
maintainable code, ultimately reducing the cost and effort of debugging and troubleshooting.
1. Why TypeScript and Why This Book 4
• Improved developer experience: TypeScript’s static typing also empowers editors and IDEs
to offer better autocompletion, type checking, and refactoring capabilities. This tooling and
editor support enhances the development experience, making it easier to write, navigate, and
maintain code. As a result, developers can be more productive and efficient in their work.
• Codebase scalability: TypeScript is designed to help manage and scale large codebases
effectively. It uses type inference to give great tooling. Its type system, checks, modular archi-
tecture, and advanced features make it easier to organize and maintain complex applications,
making TypeScript an excellent choice for both small projects and enterprise-level applications.
In other words, TypeScript gives developers better tooling at any scale.
• Strong community and ecosystem: TypeScript has a vibrant and growing community that
continually contributes to its development and offers support through various channels. The
language is backed by Microsoft, ensuring regular updates, improvements, and long-term
stability. Additionally, TypeScript’s compatibility with JavaScript means developers can
leverage existing libraries and frameworks, simplifying the adoption process and reducing the
learning curve (see bullet point Gradual adoption).
• Future-proofing: TypeScript often incorporates upcoming JavaScript features, enabling devel-
opers to use the latest language enhancements while maintaining compatibility with older
browsers and environments. This keeps TypeScript projects on the cutting edge and ensures
that developers are prepared for the future evolution of the JavaScript language.
• Gradual adoption: One of the key benefits of TypeScript is that it can be adopted incrementally.
Developers can introduce TypeScript into existing JavaScript projects without having to rewrite
the entire codebase. This allows teams with existing JavaScript code to gradually transition to
TypeScript and realize its benefits at their own pace, or keep the old JavaScript code and start
using TypeScript for new development. TypeScript can run anywhere JavaScript runs: Node.js,
Demo, Electron. Tauri, React Native.
• Code sharing: Because TypeScript has types, it’s safer, more reliable and less error prone to
use modules written in TypeScript in other modules, programs and apps. The quality goes up
and the cost and time go down. The developer experience is also greatly improved because of
autocompletion and early bug catches. TypeScript is amazing for code sharing and code reuse,
be it externally as open source or internally as inner source (to the company the developer
works at).
• Improved employability, job prospects and salary: As TypeScript become the de-facto standard
for web development (a vast if not the biggest part of software development), not being profi-
cient in it could be detrimental to your career. Moreover, survey data indicates that TypeScript
developers generally bring home heftier paychecks than their JavaScript counterparts.
In conclusion, TypeScript is a powerful and flexible programming language (and tooling) that
combines the popularity and strengths of JavaScript with additional features aimed at reducing bugs,
improving code quality, developer experience, developer productivity, and project scalability. By
choosing TypeScript, developers can write more robust, maintainable, and future-proof applications,
making it an excellent choice for modern software development projects. Next, let’s see how
TypeScript actually works.
1. Why TypeScript and Why This Book 5
• Code writing: A developer writes TypeScript code. TypeScript code is written in files with a .ts
extension. You can use all JavaScript features as well as TypeScript-specific features like types,
interfaces, classes, decorators, and more. Depending on the editors, project configurations and
build tools, the developer sees prompts, early warnings and errors (from static type checking).
• Type checking: TypeScript helps catch errors during development. You can add optional type
annotations to variables, function parameters, and return values. TypeScript’s type checker
analyzes your code and reports any type mismatches or potential issues before the code is
compiled. Type checking is done on the fly by the editor (IDE) or a compile tool in watch
mode.
• Build compilation: TypeScript code must be compiled (or “transpiled”) to plain JavaScript
before it can be executed in browsers or other JavaScript environments. The TypeScript
compiler (tsc) is responsible for this process. It takes your TypeScript source files and generates
JavaScript files that can run in any compatible environment. It’s worth mentioning that most
of the compilation is stripping down of extra code like types with some exception like down
leveling, e.g., making an async functions work in ES5.
• Bundling: At this point, JS code is bundled with other JS/TS dependencies and even CSS
and images to be ready for development, staging or production deployment. Depending on
environments, bundles will be built with different configurations. This is where tools like
Webpack, Babel, Rollup, Gulp, ESBuild come to play.
• Execution: Once your TypeScript code has been compiled to JavaScript, it can be executed just
like any other JavaScript code. You can include the generated JavaScript files in your HTML
files, serverless functions or run them in a Node.js environment, for example.
Alongside of all the five steps of our mental model of how TypeScript works at a high level,
TypeScript provides an excellent tooling support in all most popular modern code editors (IDEs)
like Visual Studio Code (VS Code), Eclipse, Vim, Sublime Text, and WebStorm. These tools are
like the magic mirror in Snow White—-always ready to give real-time feedback on type errors,
autocompletion, and code navigation features to make your development faster and more efficient.
Here’s a joke for you: Why don’t developers ever play hide and seek with their IDEs? Because good
luck hiding when they keep highlighting your mistakes!
1. Why TypeScript and Why This Book 6
Consider this example, in which we intentionally have a type mismatch. The function argument
age needs to be a number, but in the function call a string 30 is provided. The result of the function
is 301 instead of 31. However, TypeScript helps us to catch the error before even running the code by
showing us a red line and an error message Argument of type ‘string’ is not assignable to parameter
of type ‘number’.
Figure 1.1 TypeScript Playground shows errors in the editor helping to catch bugs without running
the code
In summary, TypeScript works by extending the JavaScript language with optional static types and
other features, providing better tooling and error checking. The process is simple: You craft your
TypeScript code, which then goes through a type-checked (robust, if it’s written properly or meh if
1. Why TypeScript and Why This Book 7
there are too many anys and unknowns). Then, the code gets compiled to plain JavaScript, which
can be executed in any JavaScript environment. Like a chameleon, TypeScript blends in, working
its magic anywhere JavaScript can.
Yet, TypeScript isn’t all sunshine, error-free rainbows, and sweet-smelling roses. It has its quirky,
often misinterpreted, and slippery aspects. That’s precisely the reason this book came into existence.
Now, let’s delve into how this tome is structured to lend a helping hand in your TypeScript journey.
• Basic Mistakes
• Mistakes with Types, Type Aliases and Interfaces
• Mistkaes with Functions and Methods
• Mistkaes with Classes and Constructors
• Advanced Parts and Bad Parts
The different chapters are based on their nature and impact. Each mistake will be thoroughly
explained, so you can grasp the underlying issues and learn how to avoid them in your projects.
We’ll provide examples that are as eloquent as a Shakespearean sonnet (but with more code and
fewer iambic pentameters), followed by practical solutions and best practices that you can seamlessly
integrate into your codebase.
In the appendices, you’ll set up TypeScript (for code examples), TypeScript cheat sheet and additional
TypeScript resources and further reading. Now we know what to expect but how to use the book
most effectively, you, my dear reader may ask.
I recommend reading a paper book with a cup of coffee in a comfortable ergonomic position (sofa,
armchair) and void of distractions. This way you can comfortably skim the book and get a grasp of
ideas. It’s hard to read this book on a plane, train, metro, or café due to noise and distractions but
definitely possible. Or alternatively, I recommend reading a digital book on your computer with the
code editor or playground open and ready for copy/pasted code to be run. This way you will get
a deeper understanding of topics and be able to play around with the code. Experimentation with
code will make the examples live and the reading more interactive and engaging. Experimentation
with code can lead to that “Aha!” lightbulb in your head moment.
And lastly, please don’t be frustrated with typos, omissions, and errors. Hopefully there won’t be
many because Manning has a stellar team! However, after I’ve wrote 20 books and learned that
typos and mistakes are inevitable no matter how many editors and reviewers (at readers) looked at
them. Simply submit errata to Manning for future editions. We’ll be glad you did.
Technical documentation, while necessary, is rarely riveting. That’s where this book strides in,
promising to be a shorter, focused, and significantly more enjoyable read than the docs. We’ve
carefully crafted small, digestible, yet illustrative examples—-think of them as appetizing coding
tapas, perfect for better understanding without the indigestion.
Remember, you don’t have to be great to start, but you have to start to become great. The only way
out is through, and if there’s one thing, I promise it’s this: you’re going to make it to the other side.
Because here’s the thing about mistakes: everyone makes them, but the real jesters are those who
don’t learn from them (pun intended: jesters are not related to a popular testing framework).
And remember, no matter what and how much you may hate TypeScript, keep in mind that
TypeScript loves you!
Connected to that, how does TypeScript express its love? “Our relationship has a lot of potential…
once it’s been properly defined and strictly typed.”
1.7. Summary
• TypeScript is popular and powerful language that offers myriads of benefits such as static
typing, codebase scalability, improved developer experience, gradual adoption, futureproofing,
strong community and ecosystem, and improved employability, job prospects and salary.
• TypeScript is a superset of JavaScript meaning TypeScript can do everything that JavaScript
can and then much, much more. One of its primary benefits is catching type-related errors at
compile-time rather than runtime.
• This book is designed to be a quick, fun and accessible resource for advanced-beginner level
TypeScript developers.
• By identifying, analyzing, and rectifying the 100 most common and critical TypeScript
mistakes, you’ll be well-equipped to tackle any TypeScript project with confidence and skills.
• The book contains chapters that can be grouped into four categories: TypeScript basics, Type-
Script patterns, TypeScript features, and how TypeScript works with libraries/frameworks.
• The author of the book, Azat MARDAN, has tons of experience with TypeScript, wrote best-
selling books (Practical Node.js, Pro Express, React Quickly), and worked at tech juggernauts
(Google, Capital One), medium-sized tech companies (DocuSign, Indeed) and small startups
(two exits).
• It’s not your fault that you TypeScript is hard. Once you know it, you’ll gain a lot of power.
2. Basic TypeScript Mistakes
This chapter covers
“You know that the beginning is the most important part of any work” said Plato. I add: “especially
in the case of learning TypeScript”. When many people learn basics (any basics not just TypeScript)
the wrong way, it’s much harder to unlearn them than to learn things from the beginning the proper
way. For example, alpine skiing (which is also called downhill skiing, not to confuse with country
skiing) is hard to learn properly. However, it’s easy to just ski with bad basics. In fact, skiing is much
easier than snowboarding because you can use two boards (skis) not one (snowboard). In skiing,
things like angulation (the act of inclining your body and angling your knees and hips into the turn)
don’t come easy. I’ve seen people who ski for years incorrectly which leads to increased chances
of trauma, fatigue and decreased control. We can extend the metaphor to TypeScript. Developers
who omit the basics suffer more frustration (not a scientific fast, just my observation). By the way,
why did the JavaScript file break up with the TypeScript file? Because it couldn’t handle the “type”
of commitment. Speaking of commitment, we need it to go through the book. Yes, basics are rarely
serious f*ck ups as promised in the title but I promise we’ll get to them!
• Weaker type safety: Using any reduces the benefits of TypeScript’s type system, as it disables
type checking for the variable. This can result in unnoticed runtime errors, defeating the
purpose of using TypeScript.
• Reduced code maintainability: When any is used excessively, it becomes difficult for developers
to understand the expected behavior of the code, as the type information is missing or unclear.
• Loss of autocompletion and refactoring support: TypeScript’s intelligent autocompletion and
refactoring support relies on accurate type information. Using any deprives developers of these
helpful features, increasing the chance of introducing bugs during code changes.
Let’s consider several TypeScript code examples illustrating the usage of any and its potential
downsides: using any for a function parameter, for a variable and in an array:
How could this happen? The thought process may go like this: I have some code but with an error.
What should I do?
2. Basic TypeScript Mistakes 13
In fact, is the error gone or not? The TypeScript error is gone, but the true error that is identifiable
only by scrutinizing code or running (and finding out bugs) is STILL THERE! Hence, any is rarely
a good solution to a problem.
In these examples, we use any for function parameters, variables, and arrays. While this allows us
to work with any kind of data without type checking, it also introduces the risk of runtime errors,
as TypeScript cannot provide any type safety or error detection in these cases.
To improve type safety, consider using specific types or generics instead of any.
When we use specific types for function parameters, it’ll be immediately clear without even running
the code what line is incompatible. Let’s say we want to divide by 10, so the parameter must be a
number. In this case, passing a “Hello” string is not a good idea as it won’t be assignable to the type
of number:
Using specific types for variables can also save us from time wasted debugging. If we specify a
union of string or number types, then anything else will raise a red flag by TypeScript:
#A Okay: TypeScript checks that the assigned value is of the correct type
#B Error: Type 'boolean' is not assignable to type 'string | number'.
As you saw, by avoiding any and using specific types or generics, you can benefit from TypeScript’s
type checking and error detection capabilities, making your code more robust and maintainabe.
Instead of resorting to the any type, developers can use the following alternatives:
• Type annotations: Whenever possible, specify the type explicitly for a variable, function
parameter, or return value. This enables the TypeScript compiler to catch type-related issues
early in the development process.
• Union types: In cases where a variable could have multiple types, use a union type (e.g., string
| number) to specify all possible types. This provides better type safety and still allows for
flexibility.
• Type aliases and interfaces: If you have a complex type that is used in multiple places,
create a type alias (e.g., type TypeName) or an interface to make the code more readable and
maintainable. Later, we’ll see plenty of examples about how to create both of them.
• Type guards: Use type guards (e.g., typeof, instanceof, or custom type guard functions) to
narrow down the type of a variable within a specific scope, improving type safety without
losing flexibility. We’ll cover type guards in more detail later.
• Unknown type: If you truly don’t know the type of a variable, consider using the unknown type
instead of any or omitting the type reference to let TypeScript infer the type. The unknown
type enforces explicit type checking before using the variable, thus reducing the chance of
runtime errors.
All in all, while the any type can be tempting to use for its flexibility, it should be avoided whenever
possible to maximize the benefits of TypeScript’s type system. By using type annotations, union
types, type aliases, interfaces, type guards, type inference and the unknown type, developers can
maintain type safety while still handling dynamic typing effectively.
are non-blocking compilation, advisory, and optional while errors are blocking compilation and
severe. But what’s interesting, TypeScript can “allow” certain errors. For example, this code will
compile and run outputting 100, albeit with the error message about the type mismatch “Type ‘100’
is not assignable to type ‘string’ ”:
In this sense, TypeScript is very different from other compiled languages like C++ where you
can’t run a program if it doesn’t compile! The reason is that in TypeScript type checking and
compilation/transpilation are independent processes. Hence, the paradox of seeing a type mismatch
error in our editor, but still being able to generate the JavaScript code and run it (at a huge risk).
For simplicity’s sake we’ll treat errors and warnings as a single category, although it’s possible to
configure different TypeScript ESLint rules to be “error”, “warn” or be “off”. Here are some examples
of TypeScript and ESLint TypeScript errors and warnings:
Ergo, TypeScript developers can ignore some TS errors, but ignoring these compiler and type check
errors and warnings can lead to subtle bugs, decreased code quality, and runtime errors. Ignoring
warnings kind of defeats the benefits of TypeScript. This section will discuss the importance of
addressing compiler warnings and suggest strategies for effectively managing and resolving them.
It’s good to review the consequences of ignoring compiler warnings, because ignoring compiler
warnings can result in various problems, including:
• Runtime errors: Many compiler warnings indicate potential issues that could cause unexpected
behavior or errors during runtime. Ignoring these warnings increases the likelihood of
encountering hard-to-debug issues in production.
• Code maintainability: Unresolved compiler warnings can make it difficult for other developers
to understand the code’s intent or identify potential issues, leading to decreased maintainabil-
ity.
• Type safety: TypeScript’s type system is designed to catch potential issues related to types.
Ignoring warnings related to type safety may result in type-related bugs.
2. Basic TypeScript Mistakes 16
• Increase noise: Having unsolved warnings in the code can quickly snowball into a massive
technical debt that will pollute your build terminal with noise that is useless because no action
is taken on them.
Here are TypeScript code examples illustrating the potential issues of ignoring compiler warnings,
some of them come when the strict mode is on while others can be configured in tsconfig.json
(TypeScript) or .eslintrc.js (TypeScript ESLint):
Example 1: Unused variables: Declaring a variable that is never used can lead to unnecessary code
and confusion.
Example 2: Unused function parameters: Declaring a function parameter that is never used can
indicate that the function implementation is incomplete or incorrect.
Example 3: Implicit any: Using an implicit any type can lead to a lack of type safety and make the
code less maintainable.
1 function logData(data) { // #A
2 console.log(`Data: ${data}`);
3 }
Example 4: Incompatible types: Assigning or passing values with incompatible types can lead to
unexpected behavior and runtime errors.
2. Basic TypeScript Mistakes 17
Example 5: Missing return: Not providing a return statement for all cases when return type does
not include undefined can lead to implicitly returning undefined which in turn can lead to a type
mismatch.
#A Function lacks ending return statement and return type does not include 'undefined'.
In these examples, we saw different types of compiler warnings that might occur during TypeScript
development.
By addressing these compiler warnings, you can improve the quality, maintainability, and reliability
of your TypeScript code. Ignoring compiler warnings can result in unintended consequences and
harder-to-debug issues in the future. Most importantly, having the warnings present and unsolved
increases the decay and reduces the code quality (see the theory of broken windows). The noise of
errors can hide an error that is truly important. Also, it increases the mental burden of having to
remember what errors are “expected” and okay and what aren’t.
To combat the warnings, let’s take a look at some strategies for managing and resolving compiler
warnings:
• Configure the compiler: Adjust the TypeScript compiler configuration (tsconfig.json) to match
your project’s needs. Enable strict mode and other strictness-related flags to ensure maximum
type safety and catch potential issues early on. Make no-warnings part of the build, pre-
commit, pre-merge and pre-deploy CI/CD checks. Initially it will block work because the
warnings need to be dealt with, but eventually the quality will go up and the velocity too.
• Treat warnings as errors: Configure the TypeScript compiler and ESLint to treat all warnings
(non-blocking) as errors (blocking), enforcing a policy that no code with warnings should be
pushed to the repository. This approach ensures that all warnings are addressed before merging
changes.
2. Basic TypeScript Mistakes 18
• Regularly review warnings: If you cannot fix all warnings right now, at least periodically
review and address compiler warnings, even if they don’t seem critical at the time. This practice
will help maintain code quality and reduce technical debt. If you have a huge backlog of current
warnings, have weekly or monthly TS warnings “parties” (BYOB) where you get engineers for
1-2 hours on a call to clean up the warnings.
• Refactor code: In some cases, resolving compiler warnings may require refactoring the code.
Always strive to improve code quality and structure, ensuring that it adheres to the best
practices and design patterns.
• Educate the team: Make sure that all team members understand the importance of addressing
compiler warnings and are familiar with TypeScript best practices. Encourage knowledge
sharing and peer reviews to ensure that the entire team is aware of potential issues and how
to resolve them. Be relentless in code reviews by educating and guarding against code with
warnings.
Compiler warnings in TypeScript are designed to help developers identify potential issues early
in the development process. Ignoring these warnings can result in runtime errors, decreased
maintainability, and reduced code quality. By configuring the compiler correctly, treating warnings
as errors, regularly reviewing warnings, refactoring code, and educating the team, developers can
effectively manage and resolve compiler warnings, leading to a more robust and reliable codebase…
and hopefully fewer sleepless nights being woken up by a “pager” while being on call to try to keep
the systems alive.
• Enhanced type safety: Strict mode enforces stricter type checks, reducing the likelihood of
type-related errors and making the codebase more reliable.
• Better code maintainability: With stricter type checking, the code becomes more predictable
and easier to understand, which improves maintainability and reduces technical debt.
• Improved autocompletion and refactoring support: Strict mode can improve TypeScript’s
advanced autocompletion and refactoring features, making it easier for developers to write
and modify code. This is because of the rules such as noImplicitAny that enforces to provide
a type which in turn helps with autocomplete.
• Reduced potential for runtime errors: The stricter checks introduced by strict mode help catch
potential issues early, reducing the chances of encountering runtime errors in production.
2. Basic TypeScript Mistakes 19
• Encouragement of best practices: By using strict mode, developers are encouraged to adopt
best practices and write cleaner, more robust code.
Now, here are TypeScript code examples illustrating the differences between strict and non-strict
modes:
Non-strict mode is okay with implicit any type for fn:
1 function functionFactory(fn) { // #A
2 fn(100)
3 }
4
5 functionFactory(console.log);
6 functionFactory(42); // #B
#A No TS error, but Parameter 'fn' implicitly has an 'any' type, but a better type may
be inferred from usage.
#B Runtime error calling a non-function.
Strict mode (enable by setting “strict”: true in the tsconfig.json file) and noImplicitAny are voicing
concerns on an implicit any:
1 function functionFactory(fn) { // #A
2 fn(100)
3 }
4
5 functionFactory(console.log);
6 functionFactory(42);
This error makes a developer to choose a type which in turn helps to track the runtime issue with
calling a non-function:
1 class Person {
2 name: string;
3 greet() {
4 console.log(`Hello, my name is ${this.name}.`);
5 }
6 }
With the non-strict mode, we don’t see the problem until we run the code (and that’s if we careful
enough to spot the bug!):
On the other hand, with the strict mode (enable by setting “strict”: true in the tsconfig.json file) and
strictPropertyInitialization, we can spot that something is fishy:
1 class PersonStrict {
2 name: string; // #A
3 greet() {
4 console.log(`Hello, my name is ${this.name}.`);
5 }
6 }
7
8 const personStrict = new PersonStrict("Anastasia");
9 personStrict.greet();
#A Property 'name' has no initializer and is not definitely assigned in the constructor.
To fix it, we can initialize using the property name initializer or in the constructor:
In these succinct two examples, we demonstrate the differences between strict and non-strict modes
in TypeScript in the focus of noImplicitAny and strictPropertyInitialization. The gist is that in non-
strict mode, some type-related issues may be overlooked, such as implicit any types or uninitialized
properties. Those are the big two! But there’s more to strict. So, what exactly does ‘strict’ entail? It
serves as a collective shorthand for seven distinct configurations. And these configurations include:
2. Basic TypeScript Mistakes 21
• noImplicitAny: Variables without explicit type annotations will have an implicit any type,
which can lead to type-related issues. Strict mode disallows this behavior and requires explicit
type annotations.
• noImplicit: This In strict mode, the this keyword must be explicitly typed in functions, reducing
the risk of runtime errors caused by incorrect this usage.
• strictNullChecks: Strict null checks enforce stricter checks for nullable types, ensuring that
variables of nullable types are not unintentionally used as if they were non-nullable.
• strictFunctionTypes: The strict function types flag enforces stricter checks on function types,
helping catch potential issues related to incorrect function signatures or return types.
• strictPropertyInitialization: With strict property initialization, class properties must be initial-
ized in the constructor or have a default value, reducing the risk of uninitialized properties
causing runtime errors.
• strictBindCallApply: With strict bind call apply, when using fn.apply(), TypeScript would
check the arguments types too as in a normal fn() call.
• alwaysStrict: Puts “use strict” on top of each compiled JS file.
To get the most out of TypeScript’s features, it is highly recommended to enable strict mode in the
tsconfig.json configuration file. And by the way, not using tsconfig.json or failing to commit it to
the version control system (project repository) is another big mistake!
By enabling strict mode, developers can enhance type safety, improve code maintainability, and
reduce the likelihood of runtime errors. This helps you catch potential issues early and improves
the quality and maintainability of your TypeScript code. This practice ultimately leads to a more
robust and reliable codebase, ensuring that the full potential of TypeScript’s static typing system is
utilized.
• let: Variables declared with let have block scope, meaning they are only accessible within the
block in which they are declared. let variables can be reassigned after their initial declaration.
• const: Like let, const variables have block scope. However, they cannot be reassigned after
their initial declaration, making them suitable for values that should not change throughout
the program’s execution.
2. Basic TypeScript Mistakes 22
• var: Variables declared with var have function scope, meaning they are accessible within the
entire function in which they are declared. This can lead to unexpected behavior and harder-to-
understand code due to variable hoisting, which occurs when variable declarations are moved
to the top of their containing scope.
Here are TypeScript code examples illustrating incorrect variable declaration and how to fix them.
Example 1: Using var instead of let or const. Let’s say we have a code with a var i:
When we output i which is 10, the i variable is accessible outside the loop scope, which can lead to
unexpected behavior due to possible name collision (e.g., if there’s another loop down the road of
the i variable. The fix is to use let or const for variable declaration so that we get an error trying to
access the variable outside of the scope:
#A Error: Cannot find name 'j'. The j variable is scoped to the loop and not accessible
outside.
Example 2: Incorrectly declaring a constant variable. Next, we have a constant that is defined with
let (instead of const):
Later in the code, a developer mistakenly updates the variable constantValue = 84; and boom, we
may have a bug.
The fix is to use const for constant variables:
The fix is to use const for variables that are not reassigned:
It’s worth noting that under the hood const allows TypeScript to pin down type by inferring a more
precise type. Consider this example:
As you can see, with propLiteral TypeScript inferred the type to be a string literal, not just generally
the string type.
It’s vital to note that the immutability offered by const declarations ensures that variable binding
(variable names) is immutable but not necessarily that the value itself is immutable. It can be very
surface level when it comes to values, especially when values are objects. For objects, object-like
values and arrays, while you can’t reassign them directly, their properties can still be modified.
In other words, const in JavaScript/TypeScript ensures that the variable is immutable but not
necessarily the value it references (as is the case with objects). Let’s take a look at a few examples.
For primitive values (like numbers, strings, booleans, undefined, and null), this distinction is a bit
nuanced, since the value and the variable binding are effectively the same thing. Once a primitive
is assigned, it cannot change. For example, when you do: const x = 10; You cannot reassign x to a
new value.
However, for non-primitive values (like objects and arrays), the const keyword only means you can’t
change the reference the variable points to, but the internals of the object or array can be modified.
Here’s an example:
#A This is allowed
#B TypeError: Assignment to constant variable.
In the example above, while we can’t reassign obj and arr to new objects or arrays, we can still
modify their internal values.
2. Basic TypeScript Mistakes 24
If you want to make the object’s properties (or the array’s values) themselves immutable, you’d
need additional measures like Object.freeze(). However, even Object.freeze() provides shallow
immutability. If an object contains nested objects, you’d need a deep freeze function to make
everything immutable. Here’s an example of a recursive deepFreeze() function that leverages
shallow Object.freeze():
As a rule of thumb, here are the best practices for variable declarations:
• Prefer const by default: When declaring variables, use const by default, as it enforces
immutability and reduces the likelihood of unintentional value changes. This can lead to
cleaner, more predictable code.
• Use let when necessary: If a variable needs to be reassigned, use let. This ensures the variable
has block scope and avoids potential issues related to function scope.
• Avoid var: In most cases, avoid using var, as it can lead to unexpected behavior due to function
scope and variable hoisting. Instead, use let or const to benefit from block scope and clearer
code.
• Use descriptive variable names: Choose clear, descriptive variable names that convey the
purpose and value of the variable. This helps improve code readability and maintainability.
• Initialize variables: Whenever possible, initialize variables with a (default) value when
declaring them. This helps prevent issues related to uninitialized variables and ensures that
the variable’s purpose is clear from its declaration.
By following these best practices for variable declaration, developers can create more maintainable,
predictable, and reliable TypeScript codebases. By preferring const, using let when necessary,
2. Basic TypeScript Mistakes 25
avoiding var, and choosing descriptive variable names, developers can minimize potential issues
related to variable declaration and improve the overall quality of their code.
1 interface User {
2 name: string;
3 address?: { // #A
4 street: string;
5 city: string;
6 country: string;
7 },
8 }
9
10 const userWithAddress: User = {
11 name: "Sergei",
12 address: { // #A
13 street: "Main St",
14 city: "New York",
15 country: "USA",
16 },
17 };
18
19 const user: User = {
20 name: "Sergei",
21 };
22
23 const cityDirectly = userWithAddress.address.city;
2. Basic TypeScript Mistakes 26
#A Optional field
#B Run-time error: undefined is not an object (evaluating 'user.address.city')
#C Without optional chaining - working code
#D With optional chaining
In the preceding snippet, the values for city and cityChaining are “New York” and the values
for cityUndefined and CityChainingUndefined are “undefined”. The code breaks at run-time
on cityDirectlyUndefined. Depending on the TypeScript configurations, developers can get a
very convenient warning “ ‘userWithAddress.address’ is possibly ‘undefined’ and ‘user.address’ is
possibly ‘undefined’ ” for both cityDirectly and cityDirectlyUndefined. Thus, the optional chaining
provides the safest and most eloquent way to access properties on the objects. Let’s cover the proper
use of Optional Chaining which includes the following:
• Use optional chaining to access deeply nested properties: When accessing properties several
levels deep, use optional chaining to simplify the code and make it more readable.
• Combine optional chaining with nullish coalescing: Use the nullish coalescing operator ??
in conjunction with optional chaining to provide a default value when a property is null or
undefined.
While we’ll cover more on nullish coalescing in the next section, here’s a short example:
Here is the list of common pitfalls to avoid when working with TypeScript’s Optional Chaining:
• Overusing optional chaining: While optional chaining can simplify code, overusing it can make
the code harder to read, understand and debug. For example, when too many nested properties
are optional instead of some or most of them being required, that can impair the readability.
Use optional chaining judiciously and only when it provides clear benefits.
• Ignoring potential issues: Optional chaining can mask potential issues in the code, such as
incorrect property names or unexpected null or undefined values. Going back to the preceding
example with user, if API changes the property name address to userAddress, then the code will
still work but always return undefined, making it hard to identify the source of the problem like
inconsistency or typo. Ensure that your code can handle these cases gracefully and consider
whether additional error handling or checks are necessary.
2. Basic TypeScript Mistakes 27
• Misusing with non-optional properties: Be cautious when using optional chaining with
properties that should always be present. This can lead to unexpected behavior at runtime
(e.g., missing required property causing a crash), and may indicate a deeper issue in the code
that needs to be addressed.
By using optional chaining properly and avoiding common pitfalls, developers can write cleaner
and more concise code while accessing deeply nested properties. Combining optional chaining
with nullish coalescing (??) can further improve code readability and ensure that default values are
provided when necessary. However, it is crucial to use optional chaining judiciously and remain
aware of potential issues that it may mask.
• Providing default values: Use nullish coalescing to provide a default value when a property or
variable might be null or undefined.
• Simplifying conditional expressions: Nullish coalescing can simplify conditional expressions
that check for null or undefined values, making the code more concise.
• Combining with optional chaining: Use nullish coalescing in conjunction with optional
chaining to access deeply nested properties and provide a default value when necessary.
• Misinterpreting falsy values: Nullish coalescing only checks for null and undefined. Be
cautious when using it with values that are considered falsy but not nullish, such as false,
0, or an empty string. In these cases, review the logic that needs to be implemented. If falsy
values are needed to be considered as uninitialized values (rarely the case), then consider using
the logical OR operator (||) instead. We covered the differences between logical OR and nullish
coalescing.
2. Basic TypeScript Mistakes 28
It’s worth focusing more on the difference between good old logical OR and nullish coalescing
when it comes to initializing the default values for variables. In TypeScript (as well as in JavaScript
starting with ES2020), both nullish coalescing (??) and the logical OR (||) can be used to provide
default values. However, they behave differently in specific scenarios. Here’s a breakdown of their
differences:
With the logical OR (||), the way it works is it returns the right-hand side operand if the left-hand
side operand is falsy. As you know, falsy values in JavaScript are false, null, undefined, 0, NaN, ""
(empty string), and -0. Therefore, if the left-hand side is any of these values, the right-hand side
(default/initial) value will be returned.
Here’s an example of logical OR with an empty string, zero, null and undefined:
On the other hand, the nullish coalescing (??) behaves slightly differently. It returns the right-hand
side operand only if the left-hand side operand is null or undefined. It does not consider other
falsy values (like 0, "", or NaN) as trigger conditions. This means it’s more specific in its operation
compared to ||.
Here’s an example of nullish coalescing with an empty string, zero, null and undefined:
1 const result1 = "" ?? "default"; // result1 = "" (because the empty string is not\
2 null or undefined)
3 const result2 = 0 ?? 42; // result2 = 0 (because 0 is not null or undefin\
4 ed)
5 const result3 = null ?? 42; // result3 = 42
6 const result4 = undefined ?? 42; // result4 = 42
In conclusion: use || when you want to provide a default value for any falsy value; and use ?? when
you specifically want to provide a default only for null or undefined (recommended).
In TypeScript, the distinction becomes even more important because of the typing system, which
allows for more specific handling of values and their types. The nullish coalescing operator (??) can
be particularly useful when dealing with optional properties or values that might be set to null or
undefined.
To illustrate a potential pitfall with logical OR, consider another example in which 0 could be a
correct input, e.g., 0 volume, 0 index in an array, 0 discount. However, because of the truthy check
of logical OR (||) our equation will fall back to the default value (which we don’t want to happen).
The following code is incorrect, because defaultValue will be used if inputValue is 0:
2. Basic TypeScript Mistakes 29
In this case (where 0 could be a valid value), the correct way is to use ?? or check for null. The
following two alternatives are correct because defaultValue will be used only if inputValue is null
or undefined, but not when it’s 0:
While nullish coalescing is a wonderful addition to a TypeScript programmer’s toolbox, it’s worth
noting two items to watch out for:
• Overusing nullish coalescing: Relying too heavily on nullish coalescing can lead to less
readable code and may indicate a deeper issue, such as improperly initialized variables or
unclear code logic. Evaluate whether nullish coalescing is the best solution or if a more explicit
approach would be clearer.
• Ignoring proper error handling: Nullish coalescing can sometimes be used to mask potential
issues or errors in the code. Ensure that your code can handle cases where a value is null or
undefined gracefully and consider whether additional error handling or checks are necessary.
By using nullish coalescing appropriately and being aware of its pitfalls, developers can write cleaner
and more concise code when handling default values. However, it is crucial to understand the
nuances of nullish coalescing and consider alternative approaches when necessary to maintain code
readability and robustness.
There is the modern syntax to export and import modules in TypeScript (and JavaScript) that uses
export and import statements. It’s called ES6/ECMAScript 2015 module imports. It is supported by
all modern browsers as of this writing and all main bundlers (tools that create web “binary”).
The old and not recommended approaches include but not limited to:
• CommonJS/CJS: The syntax uses require and module.exports; and it’s a legacy of Node.js.
• Loaders like Asynchronous Module Definition, Universal Module Definition, SystemJS and so
on
• Immediately Invoked Function Expression (IIFE) and an HTML script tag: while IIFE is created
to wrap the exported module to avoid global scope pollution of variable names, the script tag
loading (either static in HTML or dynamic with JS injecting the DOM element) is still widely
used for analytics and marketing services.
There are many reasons why these methods are no longer recommended with the main one being
that ES6 modules are a widely adopted standard and supported by many libraries and browsers.
ESMs have static analysis (which means imports and exports are determined at compile time rather
than at a runtime) which gives us tree shaking, predictability, autocomplete and faster lookups. Also,
when consistently using ES6 modules across all your code base, we can avoid errors, because the
mechanisms and syntax of ES6 modules differ from CJS. Even Node.js now has support for ESM!
The best practices for exporting and importing modules:
• Use named exports: Prefer named exports over default exports, which allow for exporting
multiple variables, functions, or classes from a single module. Also, named exports make it
clear which items are being exported and allow for better code organization.
• Avoid using the default exports even for single exports: If a module only exports a single item,
such as a class or function, there’s a temptation to use a default export. This can simplify
imports and make the code more readable.
2. Basic TypeScript Mistakes 31
However, opting for a default export requires the importer to decide on a name for the symbol. This
can lead to varied names for the same symbol across your codebase. It’s advisable to use a named
export to promote uniform naming conventions. Here’s an example of a bad (confusing) naming of
the imported defaults:
• Organize imports and exports: Keep imports and exports organized at the top of your module
files. This helps developers understand the dependencies of a module at a glance and makes it
easier to update or modify them.
• Be mindful of circular dependencies: Circular dependencies occur when two or more modules
depend on each other, either directly or indirectly. This can lead to unexpected behavior and
runtime errors. To avoid circular dependencies, refactor your code to create a clear hierarchy
of dependencies and minimize direct coupling between modules. For what it’s worth, circular
dependency is less of a problem with static ESM because there’s no evaluating order. This is
another good reason to use them over CJMs, script tag injection or dynamic ESM (import())
which leads us to the next point.
• Avoid using dynamic imports when possible: Dynamic imports are mainly CJMs, script tag
injections and import(). Indeed, dynamic imports can provide a certain flexibility by allowing
to load modules at a runtime, so that the exact name doesn’t have to be known before running
the program. However, they can cause more trouble than the benefits they bring.
• Export types properly: Use the type keyword for exporting only types and the consistent-type-
imports ESLint plugin to warn about not using it. The type keyword will tell TypeScript that
this module exists only in the type system and not a runtime and it can be dropped during the
transpilation.
• Avoid importing unused variables: Importing variables that are not used in the code can lead
to code bloat, decreased performance, and reduced maintainability. Many IDEs and linters can
warn you about unused imports, making it easier to identify and remove them.
2. Basic TypeScript Mistakes 32
• Leverage bundles for tree shaking: Using tools like Webpack or Rollup can help with tree
shaking, which is the process of removing unused code during the bundling process. By being
mindful of unused imports and addressing them promptly, developers can keep their code clean
and efficient and their web binaries (bundles) small which leads to improved loading time for
end users.
Some common mistakes to avoid when importing and exporting modules in plain JavaScript are
abundant:
• Forgetting to export: Ensure that you export all necessary variables, functions, classes, or
interfaces from a module to make them available for import in other modules.
• Incorrectly importing: Be cautious when importing modules and double-check that you are
using the correct import syntax for named or default exports. Misusing import syntax can lead
to errors or undefined values.
• Missing import statements: Ensure that you import all required modules in your code.
Forgetting to import a module can result in runtime errors or undefined values.
Luckily for us they are not a big deal in TypeScript, because it has our back. Omitted or incorrectly
formatted imports will result in type checking errors. A significant benefit of TypeScript is its
ability to provide a safety net, catching these kinds of mistakes, which allows us to code with more
confidence compared to plain JS.
By properly exporting and importing modules, developers can create maintainable and scalable
TypeScript codebases. Following best practices and being cautious of common mistakes helps avoid
issues related to module management, ensuring a more robust and organized codebase.
• Working with unknown types: Type assertions can be helpful when working with the unknown
type, which may require an explicit type assertion or type guards before it can be used.
• Narrowing types: Type assertions can be used to narrow down union types or other complex
types to a more specific type, provided the developer has a valid reason to believe the type is
accurate. The most common use case is removing null and undefined from union types (T|null),
i.e., null assertion operator val!.
• Interacting with external libraries: When working with external libraries that have insufficient
or incorrect type definitions, type assertions may be necessary to correct the type information.
Here’s an example of a good use of type assertion in which we know for sure that the element is an
image:
1 const el = document.getElementById("foo")
2 console.log(el.src) // #A
3 const el = document.getElementById("foo") as HTMLImageElement
4 console.log(el.src) // #B
• Overusing type assertions: Relying too heavily on type assertions can lead to less type-safe code
and may indicate a deeper issue with the code’s design. Evaluate whether a type assertion is
the best solution or if a more explicit approach would be clearer and safer.
• Ignoring type errors: Type assertions can be misused to bypass TypeScript’s type checking
system, which can lead to runtime errors and decreased type safety. Always ensure that a type
assertion is valid and necessary before using it.
• Using type assertions instead of type declarations: If it’s feasible, type declarations are preferred
because they perform excessive property checking while type assertions perform a much more
limited form of type checking.
Here’s an example of using type declaration and type assertions for variable initialization. They
both work but type declaration is preferred:
2. Basic TypeScript Mistakes 34
• Bypassing proper type guards: Instead of using type assertions, consider implementing type
guards to perform runtime checks and provide better type safety. Type guards are functions
that return a boolean value, indicating whether a value is of a specific type. We’ll cover them
in more detail later.
Here’s an example in which we illustrate two approaches: type guards and type assertions. Let’s
say we have some code that takes strings and numbers. We get an error Object is of type ‘unknown’
when we use value as number because TypeScript is unsure:
#A Object is of type 'unknown' because TypeScript is unsure about the value type inferred
from usage
1 console.log((value as number)+1)
Or we can use type guards to instruct TypeScript about types of value as follows:
Chaining type assertions with unknown: In some cases, developers may find themselves using a
pattern like unknownValue as unknown as knownType to bypass intermediary types when asserting
2. Basic TypeScript Mistakes 35
a value’s type. While this technique can be useful in specific situations, such as working with poorly
typed external libraries or complex type transformations, it can also introduce risks. Chaining type
assertions in this way can undermine TypeScript’s type safety and potentially mask errors. Use
this pattern cautiously and only when necessary, ensuring that the assertion is valid and justified.
Whenever possible, consider leveraging proper type guards, refining type definitions, or contributing
better types to external libraries to avoid this pattern and maintain type safety.
By using type assertions appropriately and being aware of their pitfalls, developers can write cleaner,
safer, and more maintainable TypeScript code. Ensure that type assertions are only used when
necessary and consider alternative approaches, such as type guards, to provide better type safety
and runtime checks.
1 console.log(42 == "42");
On the other hand, the strict equality operator ‘===’ compares two values for equality, considering
both their value and type. No type coercion is performed, making ‘===’ a safer and more predictable
choice for comparison. For example, the following statement prints/logs false because 42 is a number
and “42” is a string:
Please note that TypeScript will warn us with the message: This comparison appears to be
unintentional because the types ‘number’ and ‘string’ have no overlap. Good job, TypeScript!
The best practices for using ‘==’ and ‘===’ are as follows:
• Prefer ‘===’ for comparison: In most cases, use ‘===’ when comparing values, as it provides a
more predictable and safer comparison without type coercion.
2. Basic TypeScript Mistakes 36
• Use ‘==’ with caution (if at all): While there might be situations where using ‘==’ is convenient
(e.g., x == null serves as a handy method to verify if x is either null or undefined), be cautious
and ensure that you understand the implications of type coercion. If you need to compare
values of different types, consider converting them explicitly to a common type before using
‘==’.
• Leverage linters and type checkers: Tools like ESLint can help enforce the consistent use of
‘===’ and warn you when ‘==’ is used, reducing the risk of introducing bugs.
• Relying on type coercion: Avoid relying on type coercion when using ‘==’. Type coercion
can lead to unexpected results and hard-to-find bugs. Instead, use ‘===’ or explicitly convert
values to a common type before comparison.
• Ignoring strict inequality ‘!==’: Similar to the strict equality operator ‘===’, use the strict
inequality operator ‘!==’ when comparing values for inequality. This ensures that both value
and type are considered in the comparison.
• Confusing reference and value equality checks. When comparing objects or arrays (special
objects) are passed by reference so using === won’t cut it, because in this case the references
would be compared not values. In other words, when comparing two objects, true will be
returned only if it’s the same object (and false in all other cases, even when their properties
are not equal). Thus, it’s important to remember that for objects we need to perform a deep
comparison, that is a comparison that is performed for each child value no matter how deep
the nested structure is. Methods such as lodash.deepEqual or at the very least JSON.stringify()
can come in handy.
By understanding the differences between ‘==’ and ‘===’ and following best practices, developers
can write more predictable and reliable TypeScript code. Using strict equality and strict inequality
operators ensures that type coercion does not introduce unexpected behavior, leading to a more
maintainable and robust codebase.
In certain cases, you may want to perform a deep comparison of objects or complex types, for
which neither ‘==’ nor ‘===’ is suitable. In such situations, you can use utility methods provided
by popular libraries, such as Lodash’s isEqual function. The isEqual function performs a deep
comparison between two values to determine if they are equivalent, taking into account the structure
and content of objects and arrays. This can be particularly helpful when comparing objects with
nested properties or arrays with non-primitive values. Keep in mind, though, that using utility
methods like isEqual may come with a performance cost, especially for large or deeply nested data
structures.
Here’s a simple implementation of a deep equal comparison method for objects with nested levels
in TypeScript:
2. Basic TypeScript Mistakes 37
This deepEqual function compares two objects recursively, checking if they have the same keys and
the same values for each key. It works for objects with nested levels and arrays, as well as primitive
values. However, this implementation does not handle certain edge cases, such as handling circular
references or comparing functions.
Keep in mind that deep comparisons can be computationally expensive, especially for large or deeply
nested data structures. Use this method with caution and consider using optimized libraries, such
as Lodash, when working with complex data structures in production code.
2. Basic TypeScript Mistakes 38
1 const x = 42; // #A
2 let y; // #B
3 y = 1;
4 let z = 10; // #C
5 z = 2;
6
7 function double(value: number) { // #D
8 return value * 2;
9 }
• Provide type annotation when necessary: In some cases, TypeScript’s type inference may not
be able to deduce the correct type, or you might want to enforce a specific type. In these
situations, provide an explicit type annotation to guide the compiler.
• Provide type annotations as much as possible: Explicit annotations can make the intent clear,
especially in more complex scenarios. (This point can come as my personal opinion, and there
are still debates around the decision to explicitly annotate the return type of a function or rely
on TypeScript’s inference is often debated. Embracing type inference allows TypeScript to infer
the type of a value which in turn reduces code verbosity.)
• Use contextual typing: TypeScript’s contextual typing allows the compiler to infer types based
on the context in which a value is used. For example, when assigning a function to a variable
with a specific type Callback, TypeScript can infer the types of the function’s parameter data
and the return value.
2. Basic TypeScript Mistakes 39
A typical scenario for this is when you pass a callback to methods like map or filter. In such cases,
TypeScript can deduce the type of the function’s parameter from the array’s type:
• Leverage type inference for generics: TypeScript can infer generic type parameters based on
the types of arguments passed to a generic function or class. Take advantage of this feature to
write more concise and flexible code.
The common pitfalls to avoid when utilizing type inference in TypeScript come down to:
• Don’t be afraid of an over-annotation: While over-annotating can make the code more verbose,
less convenient to write and harder to maintain, don’t be afraid of providing type annotations
for values even when TypeScript can already infer the correct type.
• Ignoring type inference capabilities: Be aware of TypeScript’s type inference capabilities and
potential errors that it can introduce. For example, if function a is defined as function a() {
return b(); }, any change in the return type of b will automatically reflect in the inferred return
type of a. However, if you had provided an explicit type annotation for a, this automatic update
wouldn’t occur.
Embracing type inference or over-annotating, it’s your choice but you need to understand type
inference no matter what. Type inference lets the compiler deduce types automatically, and
only provide type annotations when necessary. With reliance on type inferences developers can
write more concise and maintainable TypeScript code, but this reliance can also introduce some
unexpected behaviors that over-annotation could have caught.
2. Basic TypeScript Mistakes 40
2.11. Summary
• We shouldn’t use any too often to increase the benefits of TypeScript
• We shouldn’t ignore TypeScript compiler warnings
• We should use strict mode to catch more errors
• We should correctly declare variables with let and const
• We should use optional chaining ? when we need to check for existence of a property
• We should use nullish coalescing to check ?? for null and undefined, instead of ||
• We should export and import modules properly using ES6 modules notation.
• We should understand type assertions and not over rely on unknown
• We should use === in places of == to ensure proper checks.
• We should understand the type inference capabilities and over-annotate if feasible.
3. Types, Aliases and Interfaces
This chapter covers
Getting to grips with TypeScript can feel a bit like being invited to an exclusive party where everyone
is speaking a slightly different dialect of a language you thought you knew well. In this case, the
language is JavaScript, and the dialect is TypeScript. Now, imagine walking into this party and
hearing words like “types”, “type aliases” and “interfaces” being thrown around. It might initially
sound as though everyone is discussing an unusual art exhibition! But, once you get the hang of it,
these terms will become as familiar as your favorite punchline.
Among the array of unique conversations at this TypeScript soirée, you’ll find folks passionately
debating the merits and shortcomings of type widening, type guards, type aliases and interfaces.
These TypeScript enthusiasts could put political pundits to shame with their fervor for these
constructs. To them, the intricate differences between TypeScript features are not just programming
concerns—-they’re a way of life. And if you’ve ever felt that a codebase without properly defined
types is like a joke without a punchline, well, you’re in good company. Speaking of jokes: Why did
the TypeScript interface go to therapy? —- Because it had too many unresolved properties!
But don’t worry. This chapter will guide you through the bustling crowd at the TypeScript party,
ensuring you know just when to be the life of the party and when to responsibly drive your codebase
home. After all, in TypeScript as in comedy, timing is everything. We’re going to deep dive into
these tasty TypeScript treats, learning when each one shines and how to use them without causing
a stomach problem. Along the way, we’ll learn to avoid some of the most common pitfalls like type
widening, readonly, keyof, type guards, type mapping, type aliases and others that can leave your
codebase looking like a pastry after kindergarteners. And while we are on the dessert theme, I can’t
withhold another joke: A TypeScript variable worried about gaining weight, because after all those
desserts it didn’t want to become a Fat Arrow Function!
So, get ready to embark on this exploration of types and interfaces. By the end of this chapter, you
should be able to discern between these two, just like telling apart your Aunt Bertha from your Aunt
Gertrude at a family reunion–it’s all in the details. And remember, if coding was easy, everybody
would do it. But if everyone did it, who would we make fun of for not understanding recursion?
Let’s dive in!
3. Types, Aliases and Interfaces 42
14
15 class Circle implements Point { // #C
16
17 x: number = 0;
18 y: number = 0;
19 radius: number = 10;
20 }
Please note that when we implement a type, we can add more properties like radius in the preceding
example and at the same time we must provide all the properties of the type that we implement
(Point).
Interfaces: The interface keyword is used to define a contract for objects, describing their shape
and behavior. Interfaces can be implemented by classes, extended by other interfaces, and used to
type-check objects. They cannot represent primitive values or union types.
The following example defines an interface Shape, an object that is of the type Shape, and then a
class that implements this interface:
1 interface Shape {
2 area(): number;
3 }
4
5 let shape100: Shape = { // #A
6 area: () => {
7 return 100;
8 },
9 };
10
11 class Circle implements Shape { // #B
12 radius: number;
13
14 constructor(radius: number) {
15 this.radius = radius;
16 }
17
18 area(): number {
19 return Math.PI * this.radius ** 2;
20 }
21 }
3. Types, Aliases and Interfaces 44
By the way, in some TypeScript code outside of this book, you may see interfaces postfixed
(ends) with I letter as in ShapeI. The motivation here is clear—-to differentiate between class
or type alias. However, this notation is discouraged by TS professionals as can be seen in this
GitHub discussion: https://github.co/microsoft/TypeScript-Handbook/issues/121. In my opinion
this notation is unnecessary.
To demonstrate the similarities between type aliases and interfaces, let’s see how we can rewrite our
example that used type aliases with interfaces instead. We need to replace equal signs with curly
braces, keywords type with interface and because we cannot define union type with interface, we
must create a workaround value property, as follows:
1 interface Coordinate { // #A
2 value: number | string;
3 }
4
5 const latitude: Coordinate = {
6 value: "30°47�41.841� N",
7 };
8
9 const longitude: Coordinate = {
10 value: -122.276582,
11 };
12
13 interface Point { // #B
14 x: number;
15 y: number;
16 }
17
18 let point: Point = {
19 x: 10,
20 y: 20,
21 };
22
23 class Circle implements Point {
24 x: number = 0;
25 y: number = 0;
26 radius: number = 10;
27 }
#A Union type needs to become an object type with the value property.
3. Types, Aliases and Interfaces 45
#B We don't use an equal sign = when defining interfaces unlike with type aliases.
While in the previous example type aliases and interfaces were interchangeable, here’s a cool trick
that interfaces can do (and type aliases cannot). Interfaces can be “re-opened”. The technical
term is declaration merging. This is TypeScript’s approach to representing methods introduced
in different ECMAScript versions for built-i types, such as Array. The following example illustrates
how interfaces can be “re-opened”:
1 interface User {
2 name: string;
3 }
4
5 interface User {
6 age: number; // #A
7 }
8
9 let user: User = {
10 name: "Petya",
11 age: 18, // #B
12 };
13
14 type Point = {
15 x: number;
16 };
17
18 type Point = { // #C
19 y: number;
20 };
#A This is perfectly fine; the User interface now has a name and an age.
#B Our object literal has the "added" property age.
#C This will raise a Duplicate identifier error.
The main difference between type aliases and interfaces is that type aliases are more flexible in that
they can represent primitive types, union types, intersection types, etc., while interfaces are more
suited for object type checking and class and object literal expressiveness (really all they can do!).
And as we saw, type aliases cannot be “re-opened” to add new properties vs interfaces which are
always extendable.
Here’s a short list of when to use type aliases vs. interfaces:
• Use interfaces for object shapes and class contracts: Interfaces are ideal for defining the shape
of an object or the contract a class must implement. They provide a clear and concise way to
express relationships between classes and objects.
3. Types, Aliases and Interfaces 46
• Use types for more complex and flexible structures: Type aliases are more versatile and
can represent complex structures, such as union types, intersection types, and mapped types
(operation on types that produce an object type, not a type in and of themselves per se), tuple
types or literal types, and function types (although they can be defined with an interface too).
Ergo, use types when you need more flexibility and complexity in your type definitions.
• Interfaces can “extend” / “inherit” from other types (interfaces and type aliases) but type aliases
cannot (but they can use an intersection &)
• Interfaces support declaration merging while type aliases do not.
• Type aliases can define types that cannot be represented as an interface, such as union types,
tuple types, and other complex or computed types.
Sidenote and a power tip: combining types and interfaces when necessary. In some cases, it
may be beneficial to combine types and interfaces to create more powerful and expressive
type definitions. For example, you can use a type to represent a union of multiple interfaces
or extend an interface with a type. Here’s an example in which we’ll define a few interfaces
and then combine them with types to create a union type that can represent multiple different
shapes of data.
1 interface Dog {
2 species: "dog";
3 bark: () => void;
4 }
5
6 interface Cat {
7 species: "cat";
8 purr: () => void;
9 }
10
11 interface Bird {
12 species: "bird";
13 sing: () => void;
14 }
An example function that takes a Pet type. Inside, we can use type narrowing to interact with the
pet based on its species
3. Types, Aliases and Interfaces 47
1 interactWithPet(myPet);
2 console.log(`Pet ID is ${myPet.id}.`);
In this example:
• We’ve defined three interfaces: Dog, Cat, and Bird, each representing different kinds of pets
with unique behaviors.
• We then create a Pet type that can be either a Dog, Cat, or Bird. This is the union type, allowing
us to define a variable that can hold multiple shapes of data.
• We define a function interactWithPet that accepts a Pet type and uses type narrowing to call
the appropriate method based on the pet’s species.
• We extend an interface (Pet) with additional properties (ownerName) to create a new interface
(PetWithOwner).
• We also create an intersection type (PetWithID) that combines our Pet type with an additional
id property. This is useful for cases where a pet needs to have a unique identifier.
3. Types, Aliases and Interfaces 48
By combining interfaces and types, you can create complex and flexible type definitions that can
accommodate various scenarios in a TypeScript application.
Now, you may be still asking yourself, “Which should I use? Type aliases or interfaces?”. If you ask
my opinion and as a meaning of proving you with a mental shortcut, I recommend starting with
using interfaces until or unless you need more flexibility that the types can provide. This way by
defaulting to interfaces, you’ll get the type safety and remove an extra cognitive load of constantly
thinking of what should be used here: type or interface.
Next, let’s see the most common pitfalls to avoid when working with types and interfaces in
TypeScript:
• Mixing up types and interfaces approaches: Be aware of the differences between types and
interfaces and choose the appropriate one for your use case. Once you pick the approach (e.g.,
using interfaces for declaring a shape of an object), follow it for consistency in a given project.
• Overusing union types in interfaces: While it’s possible to use union types within an
interface, overusing them can make the interface harder to understand and maintain. Consider
refactoring complex union types into separate interfaces or using types for more complex
structures. More on this we’ll cover in 3.7. Overcomplicating Types.
Let’s step aside from type aliases and interfaces and touch on a different but important topic of
types vs. values. A common error developers might encounter when working with TypeScript
is “only refers to a type but is being used as a value here.” This error occurs when a type or an
interface is used in a context where a value is expected. Since types and interfaces are only used
for compile-time type checking and do not have a runtime representation, they cannot be treated
as values. To resolve this error, ensure that you are using the correct construct for the context. If
you need a runtime value, consider using a class, enum, or constant instead of a type or interface.
Understanding the distinction between types and values in TypeScript is crucial for avoiding this
error and writing correct, maintainable code.
Here is a code example illustrating the aforementioned error: “only refers to a type, but is being
used as a value here.” This will cause the error: “MyType only refers to a type, but is being used as
a value here.”:
1 type MyType = {
2 property: string;
3 };
4
5 const instance = new MyType(); // #A
6 interface MyTypeI { // #B
7 property: string;
8 }
9
10 const instance = new MyType(); // #C
3. Types, Aliases and Interfaces 49
In this preceding example, attempting to instantiate MyType as if it were a class causes the error. To
resolve it, we define a class MyClass with the same structure as MyType and instantiate MyClass
instead. This demonstrates the importance of understanding the distinction between types and
values in TypeScript and using the correct constructs for different contexts.
All in all, by understanding the differences between type aliases and interfaces, developers can
choose the right construct for their use case and write cleaner, more maintainable TypeScript code.
Type Aliases: Best suited for defining unions, intersections, tuples, or when you need to apply
complex type transformations. Interfaces: Ideal for declaring shapes of objects and classes, especially
when you anticipate extending these types through declaration merging. Consider the strengths and
weaknesses of each construct and use them in combination when necessary to create powerful and
expressive type definitions.
3. Types, Aliases and Interfaces 50
• Use explicit type annotations: To prevent unintended type widening, you can use explicit type
annotations to specify the exact type you want for a variable or function parameter.
• Use const for immutable values: When declaring a variable with an immutable value, use the
const keyword instead of let. This will prevent type widening for primitives (not objects), as
const variables cannot be reassigned.
This is useful for configurations and such. We can also use as const with const but const is already
preventing changes by itself.
Here’s another example illustrating TypeScript type widening in action:
In this example, we define a displayText function that takes a text parameter of type string. When we
declare the greeting variable without an explicit type annotation, TypeScript automatically widens
its type to string, allowing it to be passed as an argument to the displayText function without any
issues.
Then, we declare the specificGreeting variable with an explicit type annotation of “Hello, Type-
Script!” string literal, and TypeScript does not widen the type. As a result, passing specificGreeting
to the displayText function will NOT raise a type error, since “Hello, TypeScript!” string literal is
assignable to the more general string type expected by the function (text parameter).
Here’s an example illustrating the unintentional reliance on TypeScript type widening:
1 function getPetInfo(pet: {
2 species: string;
3 age: number }) {
4 return `My ${pet.species} is ${pet.age} years old.`;
5 }
6
7 const specificDog = { species: "dog", age: 3 }; // #A
8 const dogInfo = getPetInfo(specificDog); // #B
9 specificDog.species = "cat"; // #C
10 const updatedDogInfo = getPetInfo(specificDog); // #D
3. Types, Aliases and Interfaces 52
In this example, we define a getPetInfo function that takes a pet parameter with a specific
shape. When we declare the specificDog variable without an explicit type annotation, TypeScript
automatically widens its type to , allowing it to be passed as an argument to the getPetInfo function
without any issues.
However, later in the code, a developer mistakenly updates the specificDog.species property to “cat”.
Due to type widening, TypeScript does not catch this error, and the getPetInfo function returns an
inaccurate result. This demonstrates how unintentionally relying on type widening can make the
code less maintainable and more prone to errors.
To prevent such issues, consider using explicit type annotations or creating a type alias (or interface)
to represent the expected object shape:
1 type Dog = {
2 species: "dog";
3 age: number;
4 };
5
6 const specificDog: Dog = {
7 species: "dog",
8 age: 3
9 };
10 specificDog.species = "cat"; // #A
11 specificDog satisfies Dog; // #B
Note: We can achieve the same by using an interface Dog or even an inline definition as follows:
1 const specificDog: {
2 species: 'dog', age: number
3 } = {
4 species: 'dog',
5 age: 3
6 };
The satisfies keyword is a useful tool in TypeScript for ensuring type safety and compatibility in a
way that maintains the integrity and original structure of your types. It’s especially beneficial in
3. Types, Aliases and Interfaces 53
complex codebases where strict type conformance is crucial without sacrificing the flexibility of the
types.
Now, what do you think will happen if we remove the explicit type annotation from the variable
specificDog, but leave it in the function argument pet? That is what if we have code like this:
1 type Dog = {
2 species: "dog";
3 age: number;
4 };
5
6 function getPetInfo(pet: Dog) {
7 return `My ${pet.species} is ${pet.age} years old.`;
8 }
9
10 const specificDog = { species: "dog", age: 3 };
11 const dogInfo = getPetInfo(specificDog); // #A
12 specificDog satisfies Dog; // #B
13 specificDog.species = "cat"; // #C
#A Argument of type '{ species: string; age: number; }' is not assignable to parameter
of type 'Dog'.
#B Type '{ species: string; age: number; }' does not satisfy the expected type 'Dog'
#C Okay, no TS errors which can lead to bugs.
Surprise! Widening went too far by making the specificDog property species a string, which in turn
made our object specificDog incompatible with the pet of type Dog function parameter.
By using an explicit type annotation (with an interface or a type alias), you can avoid overlooking
type widening and ensure that your code remains accurate and maintainable. On the other hand,
widening could cause a type error if it widens too far. To sum up, the most common pitfalls to avoid
when dealing with type widening in TypeScript are:
• Overlooking type widening: Be aware of when and where type widening may occur in your
code, as overlooking it can lead to unexpected behavior or type-related errors.
• Relying on type widening unintentionally: While type widening can be helpful in certain
situations, relying on it unintentionally can make your code less maintainable and more prone
to errors. Be intentional in your use of type widening, and use explicit type annotations when
necessary.
• Enjoy while type widening works as intended. It’s a good addition to the language (when
developers know how it works).
By understanding the concept of type widening and its implications, developers can write more
robust and maintainable TypeScript code. Be mindful of when and where type widening may occur,
and use explicit type annotations and the const keyword to prevent unintended widening. This will
result in a more precise and reliable type system, helping to catch potential errors at compile time.
3. Types, Aliases and Interfaces 54
1 interface InconsistentPerson {
2 age: number;
3 name: string;
4 address: string;
5 jobTitle: string;
6 }
7
8 class InconsistentEmployee implements InconsistentPerson {
9 address: string;
10 age: number;
11 name: string;
12 jobTitle: string;
13
14 constructor(
15 name: string,
16 age: number,
17 address: string,
18 jobTitle: string
19 ) {
20 this.name = name;
21 this.age = age;
22 this.address = address;
23 this.jobTitle = jobTitle;
24 }
25 }
26
27 const employee = new InconsistentEmployee(
28 "Anastasia",
29 30,
30 "123 Main St.",
31 "Software Engineer"
32 );
33
34 console.log(employee);
3. Types, Aliases and Interfaces 55
In this example, the InconsistentPerson interface and object of the InconsistentEmployee class
(employee) have their properties ordered inconsistently. This makes the code harder to read, as
developers must spend more time searching for the properties they need. It’s easy to make a mistake
in the new InconsistentEmployee() constructor call.
Now, let’s see an example with consistent property ordering:
1 interface ConsistentPerson {
2 name: string;
3 age: number;
4 address: string;
5 jobTitle: string;
6 }
7
8 class ConsistentEmployee implements ConsistentPerson {
9 name: string;
10 age: number;
11 address: string;
12 jobTitle: string;
13
14 constructor(
15 name: string,
16 age: number,
17 address: string,
18 jobTitle: string
19 ) {
20 this.name = name;
21 this.age = age;
22 this.address = address;
23 this.jobTitle = jobTitle;
24 }
25 }
26
27 const employee = new ConsistentEmployee(
28 "Pavel",
29 25,
30 "456 Main Ave.",
31 "Product Manager"
32 );
33
34 console.log(employee);
Alternatively, we can replace multiple constructor parameters with an object (with a defined custom
type). This will also help not to mess up the order of the parameters. But using a single object instead
3. Types, Aliases and Interfaces 56
of several arguments is a whole new pattern with its pros and cons which we’ll leave to others to
debate.
By consistently ordering properties in interfaces and classes, we make the code more predictable
and easier to read. This can lead to improved productivity and maintainability, as developers can
quickly find and understand the properties they need to work with.
In conclusion, maintaining a consistent order of properties in your TypeScript code is essential
for readability and maintainability. By following a predictable pattern, developers can better
understand and navigate the code, resulting in a more efficient and enjoyable development
experience.
1 interface Animal { // #A
2 name: string;
3 age: number;
4 }
5
6 interface Mammal extends Animal {} // #B
7 interface Dog extends Mammal {} // #C
8
9 const myDog: Dog = { // #D
10 name: "Buddy",
11 age: 3,
12 };
13
14 console.log(myDog); // #E
#A Base interface
#B Extended interface with no additional properties
#C Another extended interface with no additional properties
#D Usage of the extended interface
#E The Dog and Mammal interfaces add no additional value.
The base interface was extended to include the Dog and Mammal interfaces, but with no additional
properties. This means that the new interfaces bring no additional value. They just add unnecessary
3. Types, Aliases and Interfaces 57
bloat to the code. We can simplify the preceding version by removing empty interfaces Dog and
Mammal:
1 interface SimplifiedAnimal {
2 name: string;
3 age: number;
4 }
5
6 const mySimplifiedDog: SimplifiedAnimal = {
7 name: "Buddy",
8 age: 3,
9 };
10
11 console.log(mySimplifiedDog);
1 interface Person { // #A
2 name: string;
3 age: number;
4 }
5
6 interface Employee extends Person { // #B
7 title: string;
8 department: string;
9 }
10
11 interface Manager extends Employee {} // #C
12
13 const myManager: Manager = { // #D
14 name: "Anastasia",
15 age: 35,
16 title: "Project Manager",
17 department: "IT",
18 };
19
20 console.log(myManager);
#A Base interface
#B Extended interface with unrelated properties
3. Types, Aliases and Interfaces 58
The Manager interface adds no additional value, because the body of interface Manager is empty.
There are no properties Thus, we can simplify our code base. It’s worth mentioning that there’s an
ESLint rule to ban empty interfaces: no-empty-interface.
We can keep Person and Employee (more specific person with properties specific to an employee)
or just simplify into a single interface (unless Person is used elsewhere in the code):
1 interface SimplifiedEmployee {
2 name: string;
3 age: number;
4 title: string;
5 department: string;
6 }
7
8 const mySimplifiedManager: SimplifiedEmployee = {
9 name: "Anastasia",
10 age: 35,
11 title: "Project Manager",
12 department: "IT",
13 };
14
15 console.log(mySimplifiedManager);
The final SimplifiedEmployee interface is more concise and easier to understand than the initial
code. It doesn’t have an empty interface. Of course, you may be thinking, “Hey, I’ll need that empty
interface in the future” and this can be true. However, right now the code has become more complex.
The same principle of simplicity applies to not just empty interfaces but to interfaces that can be
combined or merged into other interfaces.
By looking at our example, you may think that extending interfaces is always a bad idea but that’s
not true. Extending interfaces in TypeScript isn’t inherently bad; it’s a powerful feature that allows
for more flexible and reusable code. However, whether or not it’s advisable depends on the context
and how it’s used. For example, extending interfaces is particularly good and useful when we have
a set of objects that share common properties but also have their own unique properties.
Imagine we are creating a user management system where we need to handle different types of
users: Admin, Member and Guest. All users share common properties id, name and email, but they
also have unique properties and methods. We define a base interface User and then extend it with
unique properties for each child interface:
3. Types, Aliases and Interfaces 59
1 interface User {
2 id: number;
3 name: string;
4 email: string;
5 }
Now, we can use these interfaces to define functions or classes that work with specific user types.
For example, we can create a function that take Admin as an argument and invoke it:
14
15 adminUser.createPost( // #D
16 "10 Reasons TypeScript is the 'Type' of Friend JavaScript Didn't Know It Needed!"
17 );
In this users types example, extending interfaces organizes the code, making it scalable and
maintainable, which is especially beneficial in larger or more complex TypeScript applications.
In conclusion, it’s essential to avoid unnecessary interface extension in your TypeScript code. By
keeping your interfaces concise and focused, you can improve code readability and maintainability.
Always consider whether extending an interface adds value or complexity to your code and opt for
simplicity whenever possible.
12 return "";
13 }
14
15 let startIndex = 0;
16 let endIndex = text.length - 1;
17
18 while (startIndex < endIndex && text[startIndex] === " ") {
19 startIndex++;
20 }
21
22 while (endIndex >= startIndex && text[endIndex] === " ") {
23 endIndex--;
24 }
25
26 return text.substring(startIndex, endIndex + 1);
27 }
In the example above, the complex type string | null | undefined is repeated in both function
signatures. Using a type alias (NullableString ) can simplify the code:
Using type aliases can make your code more readable by providing descriptive names for complex
types or commonly used type combinations. For example, consider this code without type alias that
has two functions that take exactly the same argument dimensions:
3. Types, Aliases and Interfaces 62
As a next step, let’s add a type alias RectangleDimensions to improve the readability (and avoid
code duplication), especially if we have to use RectangleDimensions over and over again in many
places and not just these two functions. Outlined below is how it looks with the type alias:
In the example above, using a type alias for RectangleDimensions improves the readability of the
rectangleArea and rectanglePerimiter functions signature.
Type aliases can also help encapsulate type-related logic, making it easier to update and maintain
your code (as well as improve readability and avoid code duplication).
Consider this code with a union type alias ApiResponse that has API response structure for a
successful response and a failed (error) response:
1 type ApiResponse<T> =
2 | { data: T; status: number }
3 | { status: number; error: string };
Successful response will have data of the T type and status but no error, while the failed response
would have error and status fields but no data.
3. Types, Aliases and Interfaces 63
Proceeding, we can create separate type aliases for success and error. This will allow us to use
response types elsewhere and make the code more readable. After that, we can still create a union
type for the ApiResponse type:
In the example above, using type aliases for SuccessResponse and ErrorResponse makes the union
type ApiResponse easier to understand and maintain. ApiResponse type represents any API
response. It’s a union type, so an ApiResponse can be either a SuccessResponse or an ErrorResponse.
T is again a placeholder for the type of data in the SuccessResponse. If you have an API endpoint
that returns a User, you might use these types like this:
In this case, getUser is a function that returns an ApiResponse (with data being a User object and
error none existing), or an ErrorResponse (with data none existing and error being a strin).
Keep in mind that aliases simply serve as alternative names and do not create unique or distinct
“versions” of the same type. When employing a type alias, it functions precisely as if you had
written the original type it represents. And of course, all type information will be stripped
during the compilation so the JavaScript code would not have any notion of SuccessResponse nor
ErrorResponse.
In conclusion, type aliases are an essential tool in TypeScript for promoting code readability and
maintainability. Avoid ignoring type aliases in favor of duplicating complex types or using less
descriptive type combinations. By using type aliases effectively, you can create cleaner, more
maintainable TypeScript code.
1 interface Circle {
2 type: "circle";
3 radius: number;
4 }
5
6 interface Square {
7 type: "square";
8 sideLength: number;
9 }
10
11 type Shape = Circle | Square;
12
13 function getArea(shape: Shape, shapeType: string): number {
14 if (shapeType === "circle") {
15 return Math.PI * shape.radius ** 2; // #A
16 } else {
17 return shape.sideLength ** 2; // #B
3. Types, Aliases and Interfaces 65
18 }
19 }
20
21 const myCircle: Circle = { type: "circle", radius: 5 };
22 console.log(getArea(myCircle, "circle")); // 78.53981633974483
#A Error: Property 'radius' does not exist on type 'Shape'. Property 'radius' does not
exist on type 'Square'.
#B Error: Property 'sideLength' does not exist on type 'Shape'. Property 'sideLength'
does not exist on type 'Circle'.
In the preceding example, we have a Circle and a Square interface, both belonging to the Shape type.
The getArea function calculates the area of a shape, but it doesn’t use type guards nor tagging nor
assertion. In other words, TypeScript is confusd.
To fix the errors, one of the solutions is to use type assertions (shape as Circle and shape as Square)
to access the specific properties of each shape. The type assertions in TypeScript are a way to tell the
compiler “trust me, I know what I’m doing.” Here’s how we can fix the errors with type assertions:
What if we can use a user-defined type guard (isCircle) instead of relying on assertions (as)? Here’s
a changed code example in which we introduce isCircle that returns a boolean. We can also leverage
the type property of the Circle and Square types:
1 interface Circle {
2 type: "circle";
3 radius: number;
4 }
5
6 interface Square {
7 type: "square";
8 sideLength: number;
9 }
10
11 type Shape = Circle | Square;
12
13 function isCircle(shape: Shape): shape is Circle { // #A
3. Types, Aliases and Interfaces 66
In this example, we’ve introduced a type guard function called isCircle, which narrows the type of
the shape within the if block. This makes the code safer and more efficient, as we no longer need to
use type assertions to access the specific properties of each shape. TypeScript can figure out based
on the if/else structure what type it is dealing with, Circle or Square. However, in this code it is
easy to introduce a bug. Let’s say someone unintentionally changes the body of isCircle to wrongly
compare it with a square type. Then we won’t see any TypeScript errors but we will have a run-time
bug leading to the wrong result:
1 interface Circle {
2 type: "circle";
3 radius: number;
4 }
5
6 interface Square {
7 type: "square";
8 sideLength: number;
9 }
10
11 type Shape = Circle | Square;
12
13 function isCircle(shape: Shape): shape is Circle {
14 return shape.type === "square"; // #A
15 }
16
17 function getArea(shape: Shape): number {
18 if (isCircle(shape)) {
19 return Math.PI * shape.radius ** 2; // #A
3. Types, Aliases and Interfaces 67
20 } else {
21 return shape.sideLength ** 2; // #B
22 }
23 }
24
25 const myCircle: Circle = { type: "circle", radius: 5 };
26 console.log(getArea(myCircle)); // #C
Interestingly, if we remove the isCircle completely and do the type guard check right in the function
getArea, then TypeScript is smart enough to catch inconsistency between having true for the ===
square check and trying to access the radius property:
#A Correctly shows the error: Property 'radius' does not exist on type 'Square'.
#B Also correctly shows the error; Property 'sideLength' does not exist on type 'Circle'.
As far as type guards go, when we provide a user-defined type guard (e.g., separate function isCircle),
we take away the TypeScript built-in control flow analysis. It’s better not to do it unless necessary
(e.g., code reus).
Let’s carry on with the preceding code that has a bug in it, because the logic is reversed (type square
returns area of a circle). To fix it, we must return to the correct if check shape.type === circle or
switch area statements. This is the code with the ideal approach (using type guards directly) that
has no TS errors, and makes it harder to introduce run-time errors by showing problematic areas:
3. Types, Aliases and Interfaces 68
1 interface Circle {
2 type: "circle";
3 radius: number;
4 }
5
6 interface Square {
7 type: "square";
8 sideLength: number;
9 }
10
11 type Shape = Circle | Square;
12
13 function getArea(shape: Shape): number {
14 if (shape.type === "circle") {
15 return Math.PI * shape.radius ** 2;
16 } else {
17 return shape.sideLength ** 2;
18 }
19 }
20
21 const myCircle: Circle = { type: "circle", radius: 5 };
22 console.log(getArea(myCircle)); // ~78.54
23
24 const mySquare: Square = { type: "square", sideLength: 5 };
25 console.log(getArea(mySquare)); // 25
In the given example, we use a type property on the object, because the typeof operator is not suitable
for discriminating object union members. This is because the typeof operator cannot differentiate
between object types like classes and constructor functions. It will always return object or function.
It is only useful to check the primitive types (number, string, boolean) and not objects.
Subsequently, we’ll see an example using typeof for primitives in a type guard directly in a function.
Here’s a new example with primitive types number and string that are randomly passed to the
describeType function:
3. Types, Aliases and Interfaces 69
In this example, we use a type guard function isNumber that checks if the value is a number using
the typeof operator. The describeType function then uses this type guard to distinguish between
number and string values and provide a description accordingly.
Although it may appear unassuming, there is actually a significant amount of activity happening
beneath the surface. Similar to how TypeScript examines runtime values through static types, it also
performs type analysis on JavaScript’s runtime control flow constructs, such as if/else statements,
conditional ternaries, loops, and truthiness checks, all of which can impact the types.
Within the if statement, TypeScript identifies the expression typeof value === “number” as a specific
form of code known as a type guard. TypeScript analyzes the most specific type of a value at a given
position by tracing the potential execution paths that the program can take. It is analogous to having
a single starting point and then branches of possible outcomes. TypeScript examines these unique
checks, called type guards, and assignments to create outcomes. This process of refining types (string
or number) to be more precise than initially declared (string | number) is referred to as narrowing.
In conclusion, using type guards in your TypeScript code is essential for writing safer, more efficient,
and more readable code. By narrowing the type of a variable within a specific context, you can
access the properties and methods of that type without the need for type assertions or manual type
checking.
and increased maintenance costs. In this chapter, we will discuss the problems associated with
overcomplicated types and provide suggestions on how to simplify them.
1 interface NestedType {
2 firstLevel: {
3 secondLevel: {
4 thirdLevel: {
5 value: string;
6 };
7 };
8 };
9 }
To simplify this deeply nested type, consider breaking them down into smaller, more manageable
types (using interfaces or type aliases if you prefer):
1 interface ThirdLevel {
2 value: string;
3 }
4
5 interface SecondLevel {
6 thirdLevel: ThirdLevel;
7 }
8
9 interface FirstLevel {
10 secondLevel: SecondLevel;
11 }
12
13 interface SimplifiedNestedType {
14 firstLevel: FirstLevel;
15 }
Here are the examples of valid assignments using the aforementioned types:
1 let a: SimplifiedComplexType;
2 a = "hello"; // string
3 a = 42; // number
4 a = true; // boolean
5 a = null; // null
6 a = undefined; // undefined
7 a = ["apple", "banana", "cherry"]; // Array<string>
Or suppose we have an application that deals with user settings for notifications, profile information,
and application preferences. Here are the original complex and potentially conflicting types:
1 type NotificationSettings = {
2 email: boolean;
3 push: boolean;
4 frequency: "daily" | "weekly" | "monthly";
5 };
6
7 type ProfileSettings = {
8 displayName: string;
9 biography: string;
10 email: string; // #A
11 };
12
13 type AppPreferences = {
14 theme: "light" | "dark";
15 language: string;
16 advancedMode: boolean;
17 };
18
19 type UserSettings = NotificationSettings
20 & ProfileSettings
21 & AppPreferences; // #B
3. Types, Aliases and Interfaces 72
1 type NotificationSettings = {
2 notifications: {
3 email: boolean; // #A
4 push: boolean;
5 frequency: "daily" | "weekly" | "monthly";
6 };
7 };
8
9 type ProfileSettings = {
10 profile: {
11 displayName: string;
12 biography: string;
13 contactEmail: string; // #B
14 };
15 };
16
17 type AppPreferences = {
18 preferences: {
19 theme: "light" | "dark";
20 language: string;
21 advancedMode: boolean;
22 };
23 };
24
25 type UserSettings = NotificationSettings // #C
26 & ProfileSettings
27 & AppPreferences;
The TypeScript code provided defines a generic type Overcomplicated. This type recursively maps
over the properties of an object type T and applies itself to any properties that are object types. This
is the mechanism behind it:
• Generic Type T: The type Overcomplicated is a generic type. It expects a type parameter T
which is constrained to be an object type (i.e., { [key: string]: any }). This means T must be an
object with string keys and values of any type.
• Mapped Type: Inside the Overcomplicated type, there’s a mapped type ([K in keyof T]). This
part of the code iterates over all the keys (K) in the type T.
• Conditional Type: For each key K, the type of the corresponding property is checked:
– If T[K] (the type of the property at key K in T) is an object (T[K] extends object), then
Overcomplicated is recursively applied to this property. It means if a property is an
object, the Overcomplicated type is again used for that object, allowing nested objects
to be recursively processed in the same manner.
– If T[K] is not an object (meaning it’s a primitive type like string, number, etc.), then it
retains its original type (T[K]).
In essence, this type leaves primitive types as they are, but if a property is an object, it recursively
applies the same process to that object, effectively creating a deeply nested type structure that
mirrors the original type but with the same Overcomplicated logic applied at all object levels.
This type could be useful in scenarios where you need to apply some type transformation or check
recursively through all properties of a nested object structure, especially in complex TypeScript
applications.
To simplify the Overcomplicated type, we can modify it, so that it doesn’t recursively apply itself
to nested object properties. Instead, we can just retain the type of each property as is, whether it’s
a primitive type or an object. While not exact equivalents, this will make the type mapping more
straightforward and less complex. Thus, a more straightforward version follows:
• Generic Type T: It still takes a generic type T which is an object with string keys and values of
any type.
3. Types, Aliases and Interfaces 74
This approach streamlines the type definition by removing the recursive aspect and treating all
properties uniformly, regardless of whether they’re objects or primitives. It effectively creates a
type that mirrors the structure of the original type without any additional complexity.
In conclusion, it’s essential to strike a balance between the complexity and simplicity of your types.
Overcomplicated types can decrease readability and increase maintenance costs, so be mindful of the
structure and complexity of your types. Break down complex types into smaller, more manageable
parts, and use named types or interfaces to improve readability.
1 interface ReadonlyPoint {
2 readonly x: number;
3 readonly y: number;
4 }
5
6 const point: ReadonlyPoint = {
7 x: 10,
8 y: 10,
9 };
10
11 point.x = 12; // #A
3. Types, Aliases and Interfaces 75
We are not limited to just interfaces, we can use the modifier in the class definition and even combine
with other modifiers like private and public.
1 class ImmutablePerson {
2 public readonly name: string;
3 private readonly age: number;
4
5 constructor(name: string, age: number) {
6 this.name = name; // #A
7 this.age = age;
8 }
9 }
10
11 const person = new ImmutablePerson('Ivan', 18)
12 console.log(person.name) // #B
13 person.name = 'Dima' // #C
14 console.log(person.age) // #D
15
16 person.age = 17 // #E
And yes, readonly can be used with type aliases too! Here is a TypeScript code example illustrating
the usage of a readonly modifier by having a readonly type alias with readonly properties and a
class that‘uses this type for its property center:
1 type ReadonlyPoint = { // #A
2 readonly x: number;
3 readonly y: number;
4 };
5
6 const point: ReadonlyPoint = { // #B
7 x: 10,
8 y: 20
9 };
10
11 point.x = 15; // #C
12
3. Types, Aliases and Interfaces 76
13 class Shape { // #D
14 constructor(public readonly center: ReadonlyPoint) {
15 //...
16 }
17
18 distanceTo(point: ReadonlyPoint): number {
19 const dx = point.x - this.center.x;
20 const dy = point.y - this.center.y;
21 return Math.sqrt(dx * dx + dy * dy);
22 }
23 }
24
25 const shape = new Shape(point); // #E
26 shape.center = { x: 0, y: 0 }; // #F
27 shape.center.x = 0;
28
29 const anotherPoint: ReadonlyPoint = { // #G
30 x: 30,
31 y: 40
32 };
33 const distance = shape.distanceTo(anotherPoint); // #H
In this example, we define a ReadonlyPoint type with readonly properties x and y. The Shape class
uses the ReadonlyPoint type for its center property, which is also marked as readonly. The distanceTo
method calculates the distance between the shape’s center and another point. When we attempt to
modify the x property of the point object, TypeScript raises an error because it is a readonly property.
If you would like to see a more realistic example of how readonly can help to prevent a bug, here’s
an example in which we have a class, then // Initialize the configuration with specific settings. Later
in the code, trying to modify the configuration will lead to a compile-time error:
3. Types, Aliases and Interfaces 77
1 class AppConfig {
2 readonly databaseUrl: string;
3 readonly maxConnections: number;
4
5 constructor(databaseUrl: string, maxConnections: number) {
6 this.databaseUrl = databaseUrl;
7 this.maxConnections = maxConnections;
8 }
9 }
10
11 const config = new AppConfig("https://db.example.com", 10);
12 config.databaseUrl = "https://db.changedurl.com"; // #A
Note the distinguishing between readonly in lowercase and Readonly in uppercase. In TypeScript,
these represent different concepts. While readonly refers to the modifier keyword discussed in
this section, Readonly is a built-in utility type used for marking all properties in an object type as
readonly.
The benefits of using read-only (readonly) properties are numerous:
• Immutability: readonly properties promote the use of immutable data structures, which can
make code easier to reason about and reduce the likelihood of bugs caused by unintended side
effects.
• Code clarity: Marking a property as readonly clearly communicates to other developers that
the property should not be modified, making the code’s intentions more explicit.
• Encapsulation: readonly properties help enforce proper encapsulation by preventing external
modifications to an object’s internal state.
• Use readonly class properties for immutable data: Whenever you have data that should not
change after initialization, consider using readonly properties. This is especially useful for
objects that represent configuration data, constants, or value objects.
• Apply readonly to interfaces and types: When defining an interface or type, consider marking
properties as readonly if they should not be modified. This makes the contract more explicit
and helps ensure that the implementing code adheres to the desired behavior.
• Be cautious when using readonly arrays and any object and object-like type: When marking
an array property as readonly, be aware that it only prevents the array reference from
being changed, not the array’s content. To create a truly immutable array, consider using
ReadonlyArray or the readonly modifier on array types.
1 interface Data {
2 readonly numbers: ReadonlyArray<number>;
3 }
4
5 // Alternatively
6
7 interface Data {
8 readonly numbers: readonly number[];
9 }
10
11 const data: Data = {
12 numbers: [1, 2, 3],
13 };
14
15 data.numbers = []; // #A
16 data.numbers[0] = 0; // #B
However, we should emphasize that readonly T[]and ReadonlyArray are not “deep”. For example,
this code would allow to change the value val inside of an object which is an element of an arry:
1 interface Item {
2 val: number;
3 }
4
5 interface Data {
6 readonly item: readonly Item[];
7 }
8
9 const objects: Data = {
10 item: [{ val: 0 }, { val: 1 }],
11 };
12
13 objects.item[0].val = 1; // #A
#A Ok, no errors
1 interface Item {
2 readonly val: number;
3 }
Then we’ll get an error: Cannot assign to ‘val’ because it is a read-only property.
By the way, here we can equally use type alias too, as in:
1 type Data = {
2 readonly numbers: ReadonlyArray<number>;
3 };
1 type Data = {
2 readonly numbers: readonly number[];
3 };
You may remember the shallow freeze standard JavaScript method Object.freeze() which also can be
used to enforce immutability. However, freeze and readonly work in different ways and at different
stages of the development process. Understanding the difference between freeze and readonly
differences is key for choosing the right approach based on the context of your application. Let’s
discuss how they behave differently on these levels: Runtime, depth, context, error handling and
intention.
Runtime vs. Compile-Time:
Depth of Immutability:
• Object.freeze(): It is shallow, meaning it only applies to the immediate properties of the object.
Nested objects are not frozen and can be modified.
• readonly: This keyword applies only to the property it is attached to. TypeScript does not
have a built-in deep readonly feature. However, the immutability it enforces is part of the type
system and is respected throughout the TypeScript code.
Usage Context:
3. Types, Aliases and Interfaces 80
• Object.freeze(): Used in both JavaScript and TypeScript. It is useful when you need to make
an object immutable during runtime, perhaps after some initialization logic.
• readonly: Used exclusively in TypeScript. Ideal for class properties or interface fields that
should not change after their initial setup, especially useful in large-scale applications where
type safety is a priority.
Error Handling:
• Object.freeze(): Errors (in strict mode) or silent failures (in non-strict mode) occur at runtime
if there is an attempt to modify the frozen object.
• readonly: Errors are thrown during the TypeScript compilation process, not at runtime.
• Object.freeze(): Typically used when an object needs to become immutable at a certain point
in the program’s execution.
• readonly: Clearly communicates to other developers that a property should not be modified
after its initial value is set, enhancing code readability and maintainability.
Thus, Object.freeze() provides runtime immutability and is part of JavaScript, while readonly in
TypeScript is a compile-time feature for preventing reassignment of properties. The choice between
them depends on whether you need runtime enforcement or compile-time type safety.
To sum up this section and this mistake, these are two most common pitfalls to avoid when it comes
to readonly in TypeScript:
• Forgetting to use readonly properties: Failing to use readonly properties when appropriate can
lead to unintended side effects and make the code harder to reason about. Be mindful of the
need for immutability and consider using readonly properties when immutability is necessary
and/or preferred.
• Modifying readonly properties through aliases: Be cautious when passing readonly properties
to functions or assigning them to variables, as they can still be modified through aliases. To
prevent this, consider using Object.freeze() or deep freeze libraries for deep immutability.
By using readonly properties in your TypeScript code, you can promote immutability, improve code
clarity, and enforce encapsulation. Be mindful of when to use readonly properties and consider
applying them to interfaces, types, and classes as appropriate. This will result in more maintainable
and robust code, reducing the likelihood of unintended side effects.
3. Types, Aliases and Interfaces 81
1 interface Person {
2 name: string;
3 age: number;
4 hasPet: boolean;
5 }
6
7 type PersonKeys = keyof Person; // #A
1 interface Person {
2 name: string;
3 age: number;
4 hasPet: boolean;
5 }
6
7 function getProperty(person: Person, key: keyof Person) {
8 return person[key];
9 }
10
11 const person: Person = {
12 name: "Sergiu",
13 age: 40,
14 hasPet: true
15 };
3. Types, Aliases and Interfaces 82
16
17 console.log(getProperty(person, "name")); // #A
18 console.log(getProperty(person, "address")); // #B
In the example above, using keyof Person for the key parameter enforces type safety and ensures
that only valid property keys can be passed to the getProperty function. In other words, TypeScript
will warn us that fields (such as address) that are not in Person type are not allowd.
It’s worth mentioning that we can modify the getProperty function to be general, that is suited for
any type not just Person:
We’ll cover more on Generics and of course mistakes with Generics in chapter 6.
1 Extract<Type, Union>;
This can be useful when filtering types or working with overlapping types, or when working with
libraries where you prefer to narrow down the types from a broader set. As a short example,
suppose you have a union type of all AvailableColors, and you want to create a type PrimaryColors
representing only certain members of that union:
3. Types, Aliases and Interfaces 83
Following this, let’s say we have an interface User that contains all the user information including
some very private information that shouldn’t be shared freely. Next, we can use Extract to create
type SimpleUser and use this type to enforce that only select properties (keys) are being used to
avoid leaking private user information.
1 interface User {
2 id: number;
3 name: string;
4 role: string;
5 address: string;
6 age: number;
7 email: string;
8 createdAt: string;
9 updatedAt: string;
10 dob: Date;
11 phone: string;
12 }
13
14 type SimpleUser = Extract<keyof User, "id" | "name" | "role">; // #A
15 const simpleUserProperties = ["id", "name", "role"];
16
17 function simplify(user: User): SimpleUser { // #B
18
19 return Object.keys(user)
20 .reduce((obj: any, curr: string) => {
21 if (simpleUserProperties.includes(curr))
22 obj[curr] = user[curr as keyof User];
23 return obj;
24 }, {}) as SimpleUser;
25 }
26
27 const fakeUser: User = {
28 id: 12345,
29 name: "John Doe",
30 role: "Admin",
3. Types, Aliases and Interfaces 84
#A The new type that has only select keys with Extract
#B Function that takes the full User object and returns a simplified object
#C { "id": 12345, "name": "John Doe", "role": "Admin" }
What’s interesting is that we can also pass results of keyof to Extract. Let’s see it in the next example.
Imagine that we need to create a function that would check properties between two user objects.
We would define two interfaces and then use Extract to create type SharedProperties to enforce that
only the properties (keys) of both interfaces will be used. Otherwise, I would get an error like we
have in the example when we try to use email that is not present in one of the interfaces (but id is
present in both, so it’s fine).
1 interface User {
2 id: number;
3 name: string;
4 role: string;
5 }
6
7 interface Admin {
8 id: number;
9 name: string;
10 role: string;
11 permissions: string;
12 }
13
14 type SharedProperties = Extract<keyof User, keyof Admin>;
15
16 function compareUsers(
17 user: User,
18 admin: Admin,
19 key: SharedProperties
20 ): boolean {
3. Types, Aliases and Interfaces 85
In the example above, using Extract allows us to create a SharedProperties type that includes only
the properties common to both User and Admin. This ensures that the compareUsers function can
only accept shared property keys as its third parameter.
It should be noted that, in the preceding example we can substitute our Extract with this an
intersection of type (&):
This is because they both function similarly when supplied with unions of strings (keys), that is
they work as a Venn diagram overlapping area between two circles of unions’ members. However,
there’s a difference between intersection and extract in other cases especially when working with
object types. For example, intersection of two object types (UserBasicInfo and UserPermissions) will
produce a type (CombinedUserProfile) that has all the properties of each object type:
3. Types, Aliases and Interfaces 86
1 type UserBasicInfo = {
2 id: number; // #A
3 name: string; // #A
4 email: string; // #A
5 };
6
7 type UserPermissions = {
8 canEdit: boolean; // #B
9 canDelete: boolean; // #B
10 accessLevel: number; // #B
11 };
12
13 type CombinedUserProfile = UserBasicInfo & UserPermissions; // #C
14 const userProfile: CombinedUserProfile = { // #D
15 id: 123,
16 //...
17 canEdit: true,
18 };
On the contrary, Extract with object types would be never, because UserBasicInfo is not assignable
to UserPermissions:
1 interface User {
2 id: number;
3 name: string;
4 email: string;
5 }
6
7 type PartialUser = Partial<User>; // #A
8 let user: PartialUser = {}; // #B
9
10 user.id = 1; // #C
11 user.name = "Aisha";
12
13 console.log(user); // #D
#A You can define a 'PartialUser' type that has all the same properties as 'User', but
all are optional
#B Empty object assignment is valid because all properties are optional
#C You can add properties one by one and still it'll work
#D Output: { id: 1, name: 'Aisha }
In this example, PartialUser is a type that has the same properties as User, but all of them are optional.
This means you can create a PartialUser object without any properties, and then add them one by
one.
This can be very useful when working with functions that update objects, where you only want to
specify the properties that should be updated. For example:
In this example, updateUser is a function that takes a User and a Partial and returns a new User with
the updates applied. This allows you to update a user’s email without having to specify the id and
name properties.
In conclusion, leveraging utility types like Extract and Partial can help you write cleaner, safer, and
more maintainable TypeScript code. Be sure to take advantage of this utility type when appropriate
to enhance your code’s readability and type safety.
3. Types, Aliases and Interfaces 88
3.11. Summary
• Use interfaces to define object shape. Interfaces can be extended and reopened while type
aliases cannot be.
• Use type aliases for complex types, intersections, unions, tuples, etc.
• Simplify interfaces by removing empty ones, and merging others when it makes sense.
Consider using intersection types or defining entirely new interfaces where appropriate.
• Maintain consistent property ordering in object literals and interfaces. Name properties
consistently across the classes, types and interfaces for improved readability.
• Use type safe guards instead of type assertions (as). Implement type guards where possible to
provide clearer, safer code.
• When needed, use explicit annotations to prevent type widening and ensure your variables
always have the expected type, because TypeScript automatically widens types in certain
situations, which can lead to unwanted behavior.
• Leverage readonly when it makes sense to prevent property mutation that is to ensure that
once a property is initialized, it can’t be changed. It helps in preventing accidental mutation
of properties and enforces immutability.
• Utilize keyof and extract to enforce checks on property (key) names. keyof can be used to get
a union of a type’s keys, and Extract can extract specific types from a union.
4. Functions and Methods
This chapter covers
Alright, brace yourself for a deep dive into the functional world of TypeScript and JavaScript. Why
are we focusing on functions, you ask? Well, without functions, JavaScript and TypeScript would
be as useless as a chocolate teapot. So, let’s get down to business—-or should I say “fun“ction? Eh,
no? I promise the jokes will get better!
Now, just like an Avengers movie without a post-credit scene, JavaScript and TypeScript without
functions would leave us in quite a despair. TypeScript, being the older, more sophisticated sibling,
brings to the table a variety of function flavors that make coding more than just a mundane chore.
First off, we have the humble function declaration, the JavaScript original that TypeScript inherited:
1 function greet(name) {
2 console.log(`Hello, ${name}!`);
3 }
4
5 greet("Tony Stark"); // #A
Then TypeScript, in its pursuit of stricter typing, added types to parameters and return values:
By the way, to compliment a TypeScript function just tell it that it’s very call-able.
And we also have a concept of hoisted functions. Function hoisting in JavaScript is a behavior where
function declarations are moved to the top of their containing scope during the compile phase, before
the code has been executed. This is why you can call a function before it’s been declared in your
code. However, only function declarations are hoisted, not function expressions.
1 hoistedFunction(); // #A
2
3 function hoistedFunction(): void {
4 console.log("Hello, I have been hoisted!");
5 }
Function expressions in JavaScript are a way to define functions as an expression, meaning the
function can be assigned to a variable, stored in an object, or passed as an argument to other
functions.
Unlike function declarations which are hoisted to the top of their scope, function expressions are
not hoisted, which means you can’t call a function expression before it’s been defined in your code.
Here’s a simple example of a function expression:
And let’s not forget the charming arrow functions that take us to ES6 nirvana. Short, sweet, and
this-bound, they’re the Hawkeye of the TypeScript world:
Function expressions can also be used as callbacks parameters to other functions or as immediately
invoked function expressions (IIFE) without being assigned to a variable. This is often used to create
a new scope and avoid polluting the global scope for a module or library:
4. Functions and Methods 91
1 (() => {
2 const message = "Hello, World!";
3 console.log(message);
4 })();
IIFE is a classic of JavaScript and somewhat dated now post ES2015 where we can create a scope
with a block (curly braces) but only for let and const, not for var (which we shouldn’t use anyway).
This variable is not accessible outside of this block!
1 {
2 let blockScopedVariable = "I'm block scoped!";
3 }
Time for a joke. The real reason why the TypeScript function stopped calling the JavaScript function
on the phone, is because it didn’t want to deal with any more unexpected arguments!
In this chapter, we’ll meander through the maze of function-related TypeScript snafus, armed with
a hearty jest or two and solid, actionable advice. You’re in for an enlightening journey! We’ll cover
the importance of types in functions, rest parameters (not to be confused with resting parameters
after a long day), TypeScript utility types, and ah, the infamous this. It’s like a chameleon, changing
its color based on where it is. It’s high time we take a closer look and try to understand its true
nature.
So, get comfortable, grab some espresso, and prepare for a few chuckles and plenty of ‘Aha!’
moments. This chapter promises not only to tickle your funny bone but also to guide you through
the maze of TypeScript functions and methods, one laugh at a time.
Omitting return types can lead to confusion, making it difficult for developers to understand the
intent of a function or the shape of the data it returns. This section will explore the problems that
can arise from omitting return types and provide guidance on how to avoid them.
When you don’t specify a return type for a function, TypeScript will try to infer it based on
the function’s implementation. While TypeScript’s type inference capabilities are robust, relying
on them too heavily can lead to unintended consequences. For example, if the function’s
implementation changes, the inferred return type might change as well, which can introduce bugs
and inconsistencies in your code.
By explicitly defining return types, you can prevent accidental changes to a function’s contract. This
makes your code more robust and easier to maintain in the long run, as developers can rely on the
return types to understand the expected behavior of a function. Moreover, providing return types in
your functions makes your code more self-documenting and easier to understand for both you and
other developers who may work on the project. This is particularly important in large codebases
and when collaborating with multiple developers.
Also, specifying return types helps ensure consistency across your codebase. This can be particularly
useful when working with a team, as it establishes a clear contract for how functions should be used
and what they should return. And let’s not forget about improved developer experience because with
proper function return types, IDEs can offer timely autocompletion and auto suggestions. Although
in most cases, IDEs can do this with inferred return types too, that is when we don’t explicitly
specify the return type but TypeScript tries to guess what the return type is. However, the problem
with inferred types (as you’ll see later) can sneak in when there’s an unintended change of type in
the code. Inferred type would change when it shouldn’t have been changed (which leads to a bug).
Let’s look at examples illustrating the importance of specifying return types. In this first example
we have a typical hello world function greet and it doesn’t have a return type, meaning TypeScript
will infer the type from the code as follows: the return type of the greet function is inferred as string
or undefined (i.e., function greet(name: string): string | undefined).
#A return undefined
#B Hello, Afanasiy!
#C undefined
4. Functions and Methods 93
When we pass a truthy string, the function returns a hello string, but when the string is empty (falsy
value) or absent (undefined which is also falsy), then the function returns undefined.
You may think that the empty return is superfluous because the function will return anyway. This
is true for JavaScript, albeit with TypeScript quite the opposite; without the empty return statement,
TypeScript is barking at us “Not all code paths return a value” because it doesn’t see a return for the
“else” scenario.
So, our silly-simple hello world code works. Nevertheless, by explicitly defining the return type in
the second example, you make it clear that the function can return either a string or undefined. This
enhances readability, helps prevent regressions, and enforces consistency throughout your codebase.
Moreover, having an explicit return type will prevent bugs. For example, if a cat banged its paws
on a keyboard to have 42 as the last return, then with inferred types it’ll be okay, no errors:
Conversely, with explicit return type, we would easily catch the bug:
Next let’s look at a more complex example to illustrate the importance of specifying return types
in which we have interface, types and functions. In this example, we have type/interface Book,
ApiResponse and a function processApiResponse that doesn’t have a return type. In the function
we calculate a field age “on the fly” and add that to each book (so called virtual field):
4. Functions and Methods 94
1 interface Book {
2 id: number;
3 title: string;
4 author: string;
5 publishedYear: number;
6 }
7
8 interface ApiResponse<T> {
9 status: number;
10 data: T;
11 }
12
13 function processApiResponse(response: ApiResponse<Book[]>) { // #A
14 if (response.status === 200) {
15 return response.data.map((book) => ({
16 ...book,
17 age: new Date().getFullYear() - book.publishedYear,
18 }));
19 }
20 return;
21 }
#A Without explicit return type, inferred type is { age: number; id: number; title:
string; author: string; publishedYear: number; }[] | undefined
In this example, we have a Book interface and an ApiResponse type that wraps a generic payload.
The processApiResponse function takes an ApiResponse containing an array of Book objects and
returns an array of processed books with an additional age property, but only if the response status
is 200.
We don’t specify a return type, and TypeScript infers the return type as ([] | undefined). While this
might be correct, it’s harder for other developers to understand the intent of the function.
In the following improved version, we create a ProcessedBook type and explicitly define the return
type of the function as ProcessedBook[] | undefined to make the function’s purpose and return value
clearer and easier to understand, improving the overall readability and maintainability of the code:
4. Functions and Methods 95
After that, let me illustrate for you how the processApiResponse and processApiResponseWithRe-
turnType functions are used with sample data to see that both functionally are equivalents:
22 processApiResponseWithReturnType(apiResponse); // #C
23 console.log(processedBooksWithReturnType);
Both functions will produce the same output. Nonetheless, by using the processApiResponseWith-
ReturnType function with an explicitly defined return type, you can provide better type safety,
improved code readability, and more predictable behavior for anyone who uses the function in the
future. To illustrate it, imagine that a developer made incorrect changes to the output of the response
function (with the inferred return type). We won’t be able to catch mistake:
The function without return type shown above is prone to have mistakes, because TypeScript cannot
catch them. In a function with type, you’ll get Type ‘[]’ is not assignable to type ‘ProcessedBook[]’.
Additionally, type inference is not always working properly. For example, we can have a field
for primary book cover colors that is an array of either numbers or strings because historically in
our database we’ve been first storing colors in the HEX number format but then switched to HEX
string format. Thus, we have some books with an array of strings and other books with an array of
numbers. If we have to write a function processColors to process colors, it takes an array of strings
or numbers and but wrongly returns an inferred type of array where each value can be a string or
a number:
4. Functions and Methods 97
#A Type is any[]
#B Assign items of elements to arr
#C Array is incorrectly (string | number)[] but should be string[] | number[]
#D Usage of processColors function
#E Randomly choose between generating an array of hex color strings or an array of
numbers
To fix this, we can add return type to processColors and define arr as string[] | number[].
In conclusion, allowing TypeScript to infer return types can often make your code shorter and easier
to read. It’s a TypeScript feature and it’s silly not to use it. For instance, it’s usually unnecessary
to explicitly specify return types for callbacks used with map or filter. Even more so, excessively
detailing return types can make your code more prone to break during refactoring (because there
are more places to change code). However, inferred types are not always error prone (as we saw in
the example of book colors) and can let bugs sneak in (as we saw with the API response change).
When it comes to inferred types for function return types, we should be especially careful.
Therefore, a practical guideline is to explicitly annotate return types for any part of your code that
forms an exported API, a contract between components/modules, a public interface and so on. In
other words, explicit return type as a safeguard in important places where it’s important can improve
the overall quality and maintainability of your TypeScript code. As you saw, by being explicit
about the expected return values, you can prevent potential issues, enhance readability, and promote
consistency across your projects.
4. Functions and Methods 98
In the example above, TypeScript won’t be able to catch any errors related to the callback function
because it’s defined as a generic Function. A good example in which TypeScript will alert us about
mismatched types of callback functions needs to have the callback function type defined properly:
In the good example, we define a DataCallback function type that specifies the expected input and
output types for the callback function, ensuring type safety. Of course, we can define the callback
function type inline without the extra type DataCallback, like this:
As you can see SuccessCallback represents a function that takes one parameter of type string and
does not return anything (void). On the other hand, the first parameter, success, is a function that
takes one parameter of type any and does not return anything. It’s intended to be a callback function
that gets called when the API call is successful. Let’s fix this in a good example:
By consistently using the defined function types for callbacks, you can ensure that your code is more
maintainable and less prone to errors.
• error: which is either an Error object (if an error occurred during the processing of the data)
or null (if no errors occurred).
• result: which is either a string (representing the processed data) or null (if there is no result to
return, perhaps due to an error).
Inside the processData function, we are “processing” the data argument by converting it to uppercase
(and maybe doing something more), and once that’s completed, we would call the callback function,
passing it the error (or null if there’s no error), and the result (or null if there’s no result):
1 function processData(
2 data: string,
3 callback: ( // #A
4 error: Error | null,
5 result: string | null
6 ) => void) {
7 let processedData = null;
8 try { // #B
9 processedData = data.toUpperCase(); // #C
10 callback(null, processedData);
11 } catch (error) {
12 callback(error, null);
13 }
14 }
Let’s say this callback is encountered in many other places in the code. Thus, a more optimal example
would include a new type alias ProcessDataCallback to improve code reuse:
1 type ProcessDataCallback = ( // #A
2 error: Error | null,
3 result: string | null
4 ) => void;
5
6 function processData( // #B
7 data: string,
8 callback: ProcessDataCallback
9 ) {
10 // ...Process data and invoke the callback
11 }
4. Functions and Methods 101
Of course, as we’ve seen in previous chapters, for this case an interface instead of the type alias is
feasible too. To convert the given function and type into an interface, you would define an interface
for the callback and then use that interface within the function signature. Here’s how it could look:
1 interface ProcessDataCallback { // #A
2 (error: Error | null, result: string | null): void;
3 }
4
5 function processData( // #B
6 data: string,
7 callback: ProcessDataCallback) {
8 // ...Process data and invoke the callback
9 }
This structure allows for the same functionality and type safety as the original version with a type
alias but uses an interface, which might be preferred in certain coding styles or for extending types
in more complex scenarios.
To sum up, using function types for callbacks in TypeScript is crucial for providing type safety,
consistency, and maintainability in your codebase. Lean on the side of defining appropriate function
types (inline or as a separate type alias) for your callbacks to prevent potential issues and create more
robust applications. By using a function type (inline, alias or interface) for the callback, we make
the code more explicit and easier to understand.
#A Default to 3000ms if timeout is not provided and fetch data with the actualTimeout
We use an optional parameter for timeout and default to 3000 if it’s not provided. Next let’s see
how default parameter can transform our small example. Instead, we can use a default parameter
to achieve the same effect more concisely (an added perk is that we can drop the :number type
annotation since TypeScript infers it):
After that, we can dive deeper into relying on implicit undefined values. When using optional
parameters, it’s essential to understand that, by default, they are implicitly assigned the value
undefined when not provided. This can lead to unintended behavior if your code doesn’t have
explicit checks. To avoid this issue, handle undefined values explicitly or provide default values for
optional parameters (as shown previously).
Consider the following case of a function createPerson object, where undefined values of lastName
and age can cause problems:
#B Problematic usage of implicit undefined value with TypeScript error: 'age' is possibly
'undefined'
#C Correct { fullName: 'Pavel Ivanov', isAdult: true }
#D Incorrect last name { fullName: 'Kim undefined', isAdult: false }
#E We don't know if Satish is an adult or not { fullName: 'Satish ', isAdult: false }
In this example, the problematic usage of the implicit undefined value is when checking if the age is
greater than 18. Since undefined is falsy, the comparison undefined > 18 evaluates to false. While this
might work in this particular case, it could potentially introduce bugs in more complex scenarios.
And yes, we do have a TypeScript error warning age is possibly undefined; and you even might say
because of the warning, the error is very easy to notice and fix, that it’s not an issue. Why waste
paper on this topic? And I agree with you, but because the code can still compile and run, the error
can be spotted and fixed only if a developer doesn’t have other errors that can hide this particular
error, and if this developer is disciplined about fixing all the errors (as we all should be). Hence, it’s
worth highlighting the feasible source of bugs.
A better approach would be to explicitly check for undefined to handle it appropriately (N/A) or
provide a default value for age and thus the default value for isAdult. This is an example with a
ternary expression age !== undefined:
Note, that if we just use a truthy check isAdult: (age) ? age > 18 : ‘N/A’, then all the babies aged
younger than 1 years of old (age is 0), will be incorrectly assumed as undetermined (N/A) when in
fact they should be isAdult: false. This is because a truthy check considers values 0, NaN, falsy and
an empty string as falsy when in fact they can be valid values (like our age of 0 for babies).
4. Functions and Methods 104
Lastly, placing required parameters after optional ones is kind of a mistake related to optional
parameters. Albeit it is that a very sneaking mistake (i.e., hard to not notice), because TypeScript
will warn us if we attempt to write something like this:
1 function fetchData(
2 url: string,
3 timeout?: number,
4 callback: () => void // #A
5 ) {
6 // Fetch data and call the callback
7 }
In summary, optional parameters are a powerful feature in TypeScript, but it’s crucial to use them
correctly to avoid potential issues and confusion. By following best practices such as ordering
parameters, using default parameters when appropriate, and handling undefined values explicitly,
you can create more flexible and reliable functions in your TypeScript code.
1 function sendMessage(params: {
2 to: string;
3 cc?: string;
4 attachments?: string[];
5 }) {
6 console.log(params);
7 }
8
9 sendMessage({ // #A
10 to: "[email protected]",
11 });
12
13 sendMessage({ // #B
14 to: "[email protected]",
15 cc: "[email protected]",
16 });
17
18 sendMessage({ // #C
19 to: "[email protected]",
20 attachments: ["attachment1", "attachment2"],
21 });
22
23 sendMessage({ // #D
24 to: "a",
25 cc: "b",
26 attachments: ["attachment1", "attachment2"],
27 });
4. Functions and Methods 106
#A Ok: Proper rest parameter usage with passing multiple parameters of type string
We can also have a mix of types for parameters, as follows:
The following example shows an incorrect usage and type array of arrays of strings (string[][]):
I tend to prefer array parameter over rest parameter too, because this approach is more robust (as
we have seen in the mistake with optional parameters). Which leads us to the next topic.
While this function works correctly, it might be more straightforward to accept an array of strings
instead of using a rest parameter. By accepting an array, the function signature becomes more
concise and easier to understand:
While rest parameters can be useful, overusing them can lead to overly flexible functions that are
difficult to understand and maintain. Functions with a large number of rest parameters can be
challenging to reason about and may require additional documentation or comments to explain
their behavior.
1 function createProduct(
2 name: string,
3 price: number,
4 ...attributes: string[]
5 ) {
6 // Function implementation
7 }
In this example, the createProduct function uses a rest parameter for product attributes. However,
if the function only expects a few specific attributes, it would be better to use individual parameters
or an object for those attributes:
1 function createProduct(
2 name: string,
3 price: number,
4 color: string,
5 size: string
6 ) {
7 // Function implementation
8 }
1 function createProduct(
2 name: string,
3 price: number,
4 attributes: {
5 color: string;
6 size: string;
7 }
8 ) {
9 // Function implementation
10 }
In general, it’s best to use rest parameters sparingly and only when they significantly improve the
clarity or flexibility of your code. By being mindful of these common mistakes and following best
practices when using rest parameters, you can create more flexible and clear functions in your
TypeScript code.
Indeed, this in TypeScript, as in JavaScript, refers to the context of the current scope. It’s used inside
a method to refer to the object that the function is a method of. When you call a method on an object,
the object is passed into the method as this. However, this can sometimes behave in unpredictable
ways in JavaScript, especially when functions are passed as arguments or used as event handlers.
TypeScript helps manage these difficulties by allowing you to specify the type of this in function
signatures.
These are examples on how you can use this properly in TypeScript.
You can use this inside a class to refer to the class:
1 class Person {
2 name: string;
3
4 constructor(name: string) {
5 this.name = name; // #A
6 }
7
8 sayHello() {
9 console.log(`Hello, my name is ${this.name}`); // #B
10 }
11 }
12
13 const person = new Person("Irina");
14 person.sayHello(); // #C
In Person, we defined the property name with a string type. Then we set the value of name using
this.name in constructor (initializer), so that during the instantiation of Person property name would
be set to the value passed to new Person(). The same approach can be used in other methods, not
just constructors.
In JavaScript/TypeScript, you can use this in (fat) arrow functions. (The term fat arrow function
comes from CoffeeScript which I liked, and where we also had a thin arrow function -> that is sadly
not present in JavaScript/TypeScript.) Arrow functions don’t have their own this context, so this
inside an arrow function refers to the this from the surrounding scope. In other words, the arrow
function “locks” this to the context as it’s written in code, not as it’s executed. At least this is how I
remember it. This can be useful for event handlers and other callback-based code that are “divorced”
from the context in which they are written due to the way the JavaScript event loop or a browser
DOM are executing them. For example, we all know the setTimeout function and that the event loop
will invoke it later. This code snippet tries to access (successfully) an object property (this.name)
from within the setTimeout:
4. Functions and Methods 110
1 class Person {
2 name: string;
3 constructor(name: string) {
4 this.name = name;
5 }
6 waitAndSayHello() {
7 setTimeout(() => { // #A
8 console.log(`Hello, my name is ${this.name}`); // #B
9 }, 1000);
10 }
11 }
12
13 const person = new Person("Elena");
14 person.waitAndSayHello(); // #C
In this example, if we used a regular function for the setTimeout callback, this.name would be
undefined, because this inside setTimeout refers to the global scope (or is undefined in strict mode).
However, because we used an arrow function, this still refers to the instance of the Person class.
In TypeScript, but not JavaScript, you can use this in function signature. In fact, it’s considered the
best practice is to specify this type in a function signature, if the function uses this (sets or accesses
this). For example, we have the greet function that requires this to be of a certain shape:
In this example, we’ve specified that this should be an object with a name property. If we try to call
greet() on an object without a name, TypeScript will throw an error.
Last but not least, you can leverage this in TypeScript interfaces to reference the current type.
Consider the following example that implements a method chaining for the option method.
4. Functions and Methods 111
1 interface Chainable {
2 option(key: string, value: any): this;
3 }
4
5 class Config implements Chainable {
6 options: Record<string, any> = {};
7 option(key: string, value: any): this {
8 this.options[key] = value;
9 return this;
10 }
11 }
12
13 const config = new Config();
14 config.option("user", "Ivan").option("role", "admin"); // #A
In this example, option in the Chainable interface is defined to return this, which means it returns
the current instance of the class. This allows for method chaining, where you can call one method
after another on the same object. We can also have a function return type as Config instead of this,
but the actual return in the option method has to be this and nothing else.
It’s worth noting that this supports polymorphism behavior in classes. When a method in a base
class refers to “this”, and that method is called from an instance of a subclass, “this” refers to the
instance (object) of the subclass. This ensures that the correct methods or properties are accessed,
even if they are overridden or extended in the subclass, allowing for dynamic dispatch of method
calls. Also, In TypeScript, when subclassing, you need to call the constructor of the base class using
super(). Inside the constructor of the subclass, “this” can’t be used before calling super(), because
the base class’s constructor must execute first to ensure the object is properly initialized. After the
super() call, this refers to the new subclass instance, fully initialized with the base class properties,
and can be used to further modify or set up the subclass instance.
Always be aware of the context in which you’re using this. If a method that uses this is called in a
different context (like being passed as a callback), this might not be what you expect. To mitigate
this, you can bind the method to this:
1 class Person {
2 name: string;
3
4 constructor(name: string) {
5 this.name = name;
6 this.sayHello = this.sayHello.bind(this); // #A
7 }
8
9 sayHello() {
10 console.log(`Hello, my name is ${this.name}`);
11 }
12 }
13
14 let sayHelloFn = new Person("Ivan").sayHello;
15 sayHelloFn(); // #B
#A bind sayHello to the instance of the class, without it error: undefined is not an
object
#B Outputs: "Hello, my name is Ivan"
In this preceding example, even though sayHello is called in the global context, it still correctly refers
to the instance of the Person class because we bound this in the constructor.
Remember that this binding is not necessary when using arrow functions within class properties, as
arrow functions do not create their own this context:
1 class Person {
2 name: string;
3
4 constructor(name: string) {
5 this.name = name;
6 }
7
8 sayHello = () => { // #A
9 console.log(`Hello, my name is ${this.name}`);
10 };
11 }
12
13 let sayHelloFn = new Person("Ivan").sayHello;
14 sayHelloFn(); // #B
4. Functions and Methods 113
In the aforementioned example, sayHello is an arrow function, so it uses the this from the Person
instance, not from where it’s called. This is the preferred approach to explicit this binding in the
constructor. I’m a big fan of this technique!
More so, there’s also the global this but it deserves its own section and I’ll cover it later.
The above code is suboptimal because we have this as any and because if someone tries to
(incorrectly) call the method with a different context, we won’t see any problem with it until it’s
too late. For example, this statement that doesn’t pass the proper name nor age will cause run-time
error but not the TypeScript error:
1 person.introduce.call({});
A more optimal example would have type annotation for this and an interface Person for added
type safety:
But now let’s remember that we also have a utility called ThisParameterType. It allows us to
extract this. Ergo, the most optimal (and type-safest) example would use ThisParameterType to
avoid repeating type definitions of name and age in type Person (note the use of &):
4. Functions and Methods 115
#A Type that has properties of this from introduce and introduce method
#B We can add extra properties to Person type, it doesn't have to be exactly as introduce
context
#C Hi, my name is Arjun and I am 30 years old.
#D Argument of type '{}' is not assignable to parameter of type '{ name: string; age:
number; }'.
Or for brevity (but less readability), we can combine type like this:
1 type Person = {
2 introduce(this: ThisParameterType<typeof introduce>): void;
3 } & ThisParameterType<typeof introduce>;
1 type MyFunctionType = (this: string, foo: number, bar: number) => void;
If you try to use this function in a context where a this parameter is not expected, you’ll get a type
error:
Here, callFunction expects a function that takes two number parameters, but myFunction includes
a this parameter, so it’s not compatible.
You can use OmitThisParameter to remove the this parameter:
#A No error
Here, OmitThisParameter is a type that is equivalent to (foo: number, bar: number) => void. This
means you can pass myFunction to callFunction without any type errors.
Note that OmitThisParameter doesn’t actually change the behavior of myFunction. When myFunc-
tion is called, this will be undefined, because callFunction calls fn without specifying a this value.
If myFunction relies on this being a string, you’ll need to ensure that it’s called with the correct this
value.
In conclusion, using this in TypeScript involves understanding its behavior in JavaScript and making
use of TypeScript’s features to avoid common mistakes. By declaring the type of this, you can avoid
many common errors and make your code more robust and easier to understand. And if you can
4. Functions and Methods 117
avoid using this, maybe you should because there are still a lot of developers out there for whom it is
still inherently confusing. (Instead of this, we can rewrite class-base code to use more function-style
code with plain functions, closures, or data passed explicitly through function parameters.)
1 class Person {
2 name: string;
3
4 constructor(name: string) {
5 this.name = name; // #A
6 }
7
8 greet() {
9 console.log(`Hello, my name is ${this.name}`);
10 }
11
12 greetWithMood(mood: string) {
13 console.log(
14 `Hello, my name is ${this.name}, and I'm currently feeling ${mood}`
15 );
16 }
17 }
18
19 let tim = new Person("Tim");
20 let alex = new Person("Alex");
21
22 tim.greet.call(alex); // #B
23 tim.greetWithMood.apply(alex, ["happy"]); // #C
24
25 let boundGreet = tim.greet.bind(alex); // #D
26 boundGreet(); // #E
In this example:
• call is a method that calls a function with a given this value and arguments provided
individually.
• apply is similar to call, but it takes an array-like object of arguments.
• bind creates a new function that, when called, has its this keyword set to the provided value.
As before, the key idea is that we’re able to call methods that belong to one instance of Person (Tim)
and change their context to another instance of Person (Alex).
TypeScript 3.2 introduced a strictBindCallApply compiler option that provides stricter checking for
bind, call, and apply:
In this example, TypeScript checks that the arguments passed to apply, call, and bind match the
parameters of the original function.
In Node.js, the situation is a bit different. Node.js follows the CommonJS module system, and each
file in Node.js is its own module. This means that the top-level this does not refer to the global
object (which is global in Node.js), but instead it refers to exports of the current module, which is
an empty object by default. So, in Node.js:
#A logs 'false'
#B logs '{}' or { exports: {} }
However, inside functions that are not part of any object, this defaults to the global object, unless
the function is in strict mode, in which case this will be undefined. Here’s an example:
1 function logThis() {
2 console.log(this);
3 }
4
5 logThis(); // #A
6
7 function strictLogThis() {
8 "use strict";
9 console.log(this);
10 }
11
12 strictLogThis(); // #B
In TypeScript, you can use this in the global scope, but it’s generally better to avoid it if possible,
because it can lead to confusing code. It’s usually better to use specific global variables, like window
or global, or to avoid global state altogether. The behavior of this is one of the more complex parts
of JavaScript and TypeScript, and understanding it can help avoid many common bugs.
Enter globalThis. It’s a pretty reliable way to get the global this value (and thus the global object
itself) no matter where you are. Unlike window and self, it’s working fine whether you’re in a
window context or not (like Node). So, you can get to the global object without stressing about the
environment your code’s in. Easy way to remember the name? Just think “in the global scope, this
is globalThis”. Boom.
4. Functions and Methods 120
So, In JavaScript, globalThis is a global property that provides a standard way to access the global
scope (the “global object”) across different environments, including the browser, Node.js, and
Web Workers. This makes it easier to write portable JavaScript code that can run in different
environments. In TypeScript, you can use globalThis in the same way. However, because globalThis
is read-only, you can’t directly overwrite it. What you can do is add new properties to globalThis.
For instance, if you add a new property to globalThis, you’ll get Element implicitly has an ‘any’ type
because type ‘typeof globalThis’ has no index signature:
If you try window.myGlobalProperty, then you’ll get ‘Property ‘myGlobalProperty’ does not exist
on type ‘Window & typeof globalThis’.What we need to do is to declare type:
1 myGlobalProperty;
2 window.myGlobalProperty;
3 globalThis.myGlobalProperty;
4 globalPerson.name;
5 window.globalPerson.name;
6 globalThis.globalPerson.name;
In this example, declare global extends the global scope with a new variable myGlobalProperty.
After this declaration, you can add myGlobalProperty to globalThis without any type errors.
4. Functions and Methods 121
Remember that modifying the global scope can lead to confusing code and is generally considered
bad practice. It can cause conflicts with other scripts and libraries and makes code harder to test and
debug. It’s usually better to use modules and local scope instead. However, if you have a legitimate
use case for modifying the global scope, TypeScript provides the tools to do it in a type-safe way.
Another common use of globalThis in TypeScript and JavaScript is to check for the existence of global
variables. For example, in a browser environment, you might want to check if fetch is available:
1 if (!globalThis.fetch) {
2 console.log("fetch is not available");
3 } else {
4 fetch("https://example.com")
5 .then((response) => response.json())
6 .then((data) => console.log(data));
7 }
In this example, globalThis.fetch refers to the fetch function, which is a global variable in modern
browsers. If fetch is not available, the code logs a message to the console. If fetch is available, the
code makes a fetch request.
This can be useful for feature detection, where you check if certain APIs are available before you
use them. This helps ensure that your code can run in different environments.
Remember, it’s better to avoid modifying the global scope if you can, and to use globalThis
responsibly. Modifying the global scope can lead to conflicts with other scripts and libraries and
makes your code harder to test and debug. It’s usually better to use modules and local scope instead.
In modern JavaScript and TypeScript development, modules provide a better and more flexible way
to share code between different parts of your application.
1 type FunctionSignatureObjectType = {
2 // ... Some properties
3 (param1: Type1, param2: Type2, ...): ReturnType; // #A
4 };
#A Function signature
To understand this better, let’s look at an example of the function signature in an object type Greeter:
4. Functions and Methods 122
1 type Greeter = {
2 (name: string): string; // #A
3 };
4
5 const sayHello: Greeter = (name) => `Hello, ${name}!`; // #B
6 console.log(sayHello("Alisa")); // #C
In this example, Greeter is an object type with a call signature. It specifies that any object of type
Greeter is actually a function that takes a single string parameter and returns a string. The sayHello
function is then defined to match this call signature. It takes a string name and returns a greeting
message, also as a string.
Moreover, in TypeScript you can define an object type with both properties and call signatures. This
means the object can have regular properties (like numbers, strings, etc.) and also be callable as a
function. Here’s an example of how you might define and use such an object (using type alias or
interface):
1 interface UserCreator {
2 defaultId: string; // #A
3 defaultName: string;
4 (name: string, id: string): User; // #B
5 }
6
7 interface User {
8 name: string;
9 id: string;
10 }
#A Properties
#B Call signature
In this example, UserCreator is an object type with two properties, defaultId and defaultName, and
a call signature that creates a user when called with a name and an id. Next, let’s implement a
specific instance of UserCreator:
4. Functions and Methods 123
In this example, the UserCreator type is defined as an object that functions both as a creator
for a User and as a holder for default user properties. The createUser function, instance of the
UserCreator type, requires two parameters to return a User, and internally, it utilizes the defaultId
and defaultName from its own context, known as this, which refers to the function object. Before
these default properties can be utilized, they must be specifically assigned to the createUser object.
When createUser is called, much like any standard function, it generates new User objects. Notably,
if provided with empty strings, createUser will instead apply the default values that have been set to
its properties. This works because in JavaScript all functions are objects, hence we are able to have
properties on the function object createUser.
To sum it up, using function signatures in object types along with additional properties allows you
to create rich, stateful functional objects that encapsulate both behavior and data in a structured
way. This can be especially useful in scenarios like factory functions, configurable functions, or
when mimicking classes while leveraging function flexibility.
4. Functions and Methods 124
In the example above, the second overload signature expects a number as the second argument, but
the function call passes a string instead. This causes a type error, as no matching overload is found.
This can be fixed by adding a matching overload signature:
4. Functions and Methods 125
#A Overlapping overloads
#B It works but which overload is used?
In the example above, the two signatures are very similar, as both accept a string as the first argument
and have different types for the second argument. This can lead software engineers to confusion
about which signature is being used in a given context. This is because it’s not immediately clear
which overload is being used when calling format(“Hello”, 5). While the TypeScript compiler can
4. Functions and Methods 126
correctly infer the types and use the appropriate overload, the ambiguity may cause confusion for
developers trying to understand the code.
A better approach would be to simply remove the overloads as shown in the following code listing:
Another approach if more parameters are needed is to enhance the overload signatures to avoid
ambiguity, in this case padding with a string and specifying direction:
#A Excessive overloads
#B Complex implementation with many overloads
In the example above, using four overloads increases the complexity of the function. Simplifying
the implementation by leveraging union types, optional parameters, or default values can improve
readability and maintainability.
Excessive overloads can be fixed by getting rid of overloads and simplifying the function signature
using union types:
4. Functions and Methods 128
In conclusion, using function overloads effectively can greatly enhance type safety and precision in
your TypeScript code. However, it’s important to avoid common mistakes, such as mismatched
signatures, overlapping overloads, and excessive overloads, to ensure your code remains clean,
maintainable, and bug-free.
1 type MyFunction = {
2 (x: number, y: number): number;
3 (x: string, y: string): string;
4 };
5
6 const myFunction: MyFunction = (x, y) => { // #A
7 if (typeof x === "number" && typeof y === "number") {
8 return x + y;
9 } else if (typeof x === "string" && typeof y === "string") {
10 return x + " " + y;
11 }
12 throw new Error("Invalid arguments");
13 };
14
15 console.log(myFunction(1, 2));
16 console.log(myFunction("Hao", "Zhao"));
#A Type '(x: string | number, y: string | number) => string | number' is not assignable
to type 'MyFunction'.
The correct example would have the type with unions where the declaration of a type alias
MyFunction is defined as a function type that takes two parameters, x and y. Each parameter can
be either a number or a string (as indicated by the | which denotes a union type). The function is
expected to return either a number or a string:
1 type MyFunction = (x: number | string, y: number | string) => number | string;
In this version of the code, when x and y are strings, the function uses the concatenation method
to combine them, which ensures that the operation is understood as string concatenation, not
numerical addition.
4. Functions and Methods 130
An alternative example would have separate functions to avoid overloading functions (and type
guards):
1 type MyFunctionNum = {
2 (x: number, y: number): number;
3 };
4
5 type MyFunctionStr = {
6 (x: string, y: string): string;
7 };
8
9 const myFunctionStr: MyFunctionStr = (x, y) => {
10 return x.concat(" ").concat(y);
11 };
12
13 const myFunctionNum: MyFunctionNum = (x, y) => {
14 return x + y;
15 };
16
17 myFunctionNum(1, 2);
18 myFunctionStr("Hao", "Zhao");
1 type CalculationOperation = (
2 a: number,
3 b: number,
4 op?: "add" | "subtract" | "multiply" | "divide"
5 ) => number;
6
7 const complexCalculation: CalculationOperation = (a, b, op = "add") => {
8 switch (op) {
9 case "add":
10 return a + b;
11
12 case "subtract":
13 return a - b;
4. Functions and Methods 131
14
15 case "multiply":
16 return a * b;
17
18 case "divide":
19 return a / b;
20
21 default:
22 throw new Error(`Unsupported operation: ${op}`);
23 }
24 };
25
26 console.log(complexCalculation(4, 2, "subtract")); // #A
27 console.log(complexCalculation(4, 2)); // #B
#A Outputs: 2
#B Outputs: 6 (default is 'add')
By using function types correctly, you can leverage TypeScript’s type system to enforce consistency
and improve the maintainability of your code.
4. Functions and Methods 132
#A TypeScript error: ';' expected because we try to implement the function in a type
alias.
To fix this, we must separate the type from the function definition itself (as a function expression
assigned to a variable of type MyFunction). Here’s a correct code:
#A Function type
#B Function implementation
In light of this, TypeScript is giving us an error but the error is saying something about a semicolon
and it’s not immediately obvious. Reading about this blunder can save you a few minutes of confused
staring at the code.
This function type accepts any number of arguments of any type and returns a value of any type.
Of course, as discussed previously, it lessens the benefits of TypeScript. It’s much better to use more
specific function types that accurately describe the expected inputs and outputs:
We’ve covered a lot of ground in terms of applying function types and their best practices. To sum it
up: types are good (instead of generic any or no types), simple is good (instead of overcomplicating).
4. Functions and Methods 133
1 function exampleFunction( // #A
2 a: number,
3 b: string
4 ): boolean {
5 // ... some operations
6 return true;
7 }
8
9 type ExampleFunctionType = typeof exampleFunction; // #B
10 let myFunction: ExampleFunctionType; // #C
11
12 myFunction = (num: number, str: string): boolean => { // #D
13 // ... some operations
14 return false;
15 };
#A Defining a function (imagine it's outside your code and we can't change it)
#B Using 'typeof' to extract the function type
#C Now you can use the extracted type for other variables or parameters
#D Assigning a function to 'myFunction' that matches the 'exampleFunction' signature
In this example: exampleFunction is a simple function that takes a number and a string as parameters
and returns a boolean. Consider it being outside of your code so that it’s impossible to change
its code to use the same type as myFunction. Next, ExampleFunctionType uses typeof to extract
4. Functions and Methods 134
the type signature of exampleFunction. This type includes the parameter types and return type of
exampleFunction.
Then, myFunction is then declared with the type ExampleFunctionType, meaning it should be a
function with the same signature as exampleFunction. By using typeof to extract and reuse function
types, you maintain consistency and reduce redundancy, especially when dealing with complex
functions or when you need to ensure multiple functions share the same signature across your
codebase.
In the example above, the return type of sum is manually defined inline as a number, and the same
return type is specified again in type alias Calculation. Also, we can let TypeScript infer the type of
multiply by having this (previously we covered how inference works and what are some of its pros
and cons):
4. Functions and Methods 135
Interestingly, we would reuse the return type of the function sum. By using ReturnType, the
return type of sum is automatically inferred and used in Calculation, reducing code repetition and
improving maintainability.
You may think that this example is silly because why wouldn’t you use Calculation for sum directly
as we did for multiply, instead of using ReturnType? That’s because functions like sum can be
defined in a different module or a library (authored by other developers) so we don’t have rights to
augment code for sum. At the same time, we want the return types to match. In situations like this
ReturnType can come in handy.
Alternatively in this particular example, you can replace the whole type like this:
However, that’s a very different approach than just pulling the return type out of sum because it
assigns the entire type not just return type. It’s less flexible. This way we cannot modify parameters
if we want but with ReturnType approach, the function parameters can be different for Calculation
than for sum.
Here’s another more complex example of ReturnType that showcases the declaration of a function
fetchData and a type FetchDataResult, followed by the definition of another function processData.
4. Functions and Methods 136
The function fetchData fetches some data from a given URL, a type FetchDataResult represents the
result of the fetched data, and the function processData processes the fetched data using a provided
fetch function callback.
The fetchData function return type is exactly the same as the return type of the callback function to
processData:
The fetchData function takes a url parameter of type string and returns a Promise that resolves to
an object with a data property of type any. This function is responsible for fetching data from
the specified URL. The FetchDataResult type is defined as a Promise that resolves to an object
with a data property of type any. This type is used to describe the expected return type of the
fetchFn function parameter in the processData function. The processData function takes a function
parameter fetchFn which is defined as a function accepting a url parameter of type string and
returning a FetchDataResult. This function is responsible for processing the fetched data.
Hence, in the example above, the return type of fetchData is repeated twice, which can be error-
prone and harder to maintain. And let’s say we can update code for fetchData for some reason or
another. Considering this, a better example would be leverage ReturnType to avoid code duplications
that can lead to errors when modified only in one place and not all the places:
#A Imagine this is outside of our code in some library over which we don't have control
#B Used the ReturnType utility type
Indeed, by using the ReturnType utility type, we simplify the code and make it easier to maintain.
4. Functions and Methods 137
In the example above, the parameter types of standardGreet are manually specified again in
MyGreeting. We can do better than that, right? Of course! Let’s utilize Parameters to “extract”
function parameters from standardGreet while the rest of the code can remain the same:
By using Parameters, the parameter types of standardGreet are automatically inferred and used in
MyGreeting, making the code cleaner and more maintainable. Next, I would like to demonstrate a
few more examples and use cases of Parameters. We start with this code:
4. Functions and Methods 138
The next example demonstrates using Parameters to assign specific arguments to params1 and then
invoking standardGreet with the spread operator:
After that, the next example showcases the use of tuple types by declaring params2 with the as const
assertion to ensure the literal types of the arguments:
#A Output: NO
#B Output: Saludos, Jose! Tienes 42 años.
In conclusion, TypeScript’s utility types for functions can help you create more efficient, maintain-
able, and expressive code. By leveraging utility types like ReturnType, and Parameters, you can
reduce code repetition and make your codebase more resilient to changes. Always consider using
utility types when working with functions in TypeScript to get the most out of the language’s type
system.
4. Functions and Methods 139
4.12. Summary
• Always specify return types for functions to ensure proper type checking and prevent
unexpected behavior.
• Use optional and rest parameters judiciously, considering their impact on function behavior
and readability. Always put optional parameters after the required parameters in the function
signature calls. And put rest parameters last.
• Always specify the return type of a function to ensure type safety and provide clear expecta-
tions to callers.
• Leverage utility types like Parameters, ReturnType, and ThisParameterType to enhance type
safety and improve code quality in functions.
• Use arrow functions or explicit binding to maintain the desired this context. Always set the
shape/type of this. Understand the differences between bind, call, apply, and strictBindCallAp-
ply for manipulating the this context.
• Use globalThis instead of environment-specific global objects (window, global, etc.) for better
portability.
• Utilize utility types like Parameters, ReturnType, and ThisParameterType to improve code
quality and correctness.
5. Classes and Constructors
This chapter covers
Historically, JavaScript was designed as a functional language with some object-oriented program-
ming (OOP) features. In a way, JavaScript tries to be both a functional language and an OOP
language. As an example, Array.map() and Array.reduce() are very functional methods, while const
tesla = Object.create(Car) is OOP. A few years ago, the class syntax was added to JavaScript (a.k.a.
syntactic sugar). Before this introduction, software engineers had to write prototypal or function
factory inheritances. However, class is just a syntax, that under the hood still follows the good old
prototype-based model for better or worse. In relation to the class, let me ask you this question: why
did the JavaScript file become a TypeScript file? Because it wanted to get in touch with its “inner
class”! :-)
In modern day and age, most developers just use class and don’t bother to understand it. But I know
you are a more thorough type of a person because you bought this book. Indeed, it’s fundamental
to grasp the prototypal nature of JavaScript/TypeScript to avoid many pesky mistakes (with class
too). And understanding JavaScript’s prototypal inheritance is like trying to untangle headphones
that have been in your pocket for a week. Just when you think you’ve figured out the pattern, you
find another knot! Thus, let’s brush up on these concepts to really understand nuts and bolts.
First, please consider this example that uses prototypal inheritance to achieve OOP-like inheritance
in JavaScript/TypeScript in which we define an object prototype Car that is just like any other
objects. It has two methods init and describe.
5. Classes and Constructors 141
1 const Car = {
2 init: function (brand, model) {
3 this.brand = brand;
4 this.model = model;
5 },
6
7 describe: function () {
8 return `${this.brand} ${this.model}`;
9 },
10 };
The Car object that we just defined can be used akin to a class (albeit with caveats). So, we can
create a new object (instance) tesla based on the Car object (which is called and acts as a prototype):
In the above code, Car serves as an object prototype. This is analogous to a class in other OOP
languages: tesla can invoke methods init and describe. In a way by using Object.create(Car), we
generated a new object that is “an instance” of Car. Nonetheless, it’s not a true class. If we inspect
tesla closer, brand and model will be attributes (properties) of this object, but describe and init will
be attributes of its prototype:
The Car object itself has a prototype like any JavaScript/TypeScript object. This is how we get
functions like toString().
5. Classes and Constructors 142
And you may wonder why does it matter if a method is on a prototype or an instance? It matters
because when or if the prototype method is changed, it’ll be automatically changed on all instances.
For example, changing describe on Car will automatically change it on tesla leading to “Toyota
Corolla” output instead of “Tesla Model S” as previously:
Depending on whom you ask, this prototypal “linkage” to “class” could be a feature or a bug. To me
it’s more of a bug because it nipped me in the rear many times!
With ES6, JavaScript adopted the class syntax as syntactic sugar over this prototype-based system
to provide a more familiar syntax for programmers coming from class-based OOP languages. Here
is how the above example would be rewritten using ES6 classes. First, we define a class Car which
is a class with a constructor and a method named describe:
5. Classes and Constructors 143
1 class Car {
2 constructor(brand, model) {
3 this.brand = brand;
4 this.model = model;
5 }
6
7 describe() {
8 return `${this.brand} ${this.model}`;
9 }
10 }
Now we can create new instances of the Car class. A new instance of Car is created with a new
Car(“Tesla”, “Model S”), and this instance has access to the describe method from the class definition.
Consequently, we create another instance alfaRomeo.
As a result, changing the class’s describe will lead to new behaviors in all instances (different
outputs):
1 Car.prototype.describe = function() { // #A
2 return `${this.model} - ${this.brand}`
3 }
4
5 tesla.describe() // #B
6 alfaRomeo.describe() // #C
#A Class puts the properties in the prototype of Car unlike the previous example with
object-based prototypes
#B Model Y - Tesla
#C Giulietta - Alfa Romeo
And by the way, the naming of “class” (or prototype object) is totally conventional meaning, you
don’t have to upper case it (e.g., Car) but that’s what is the accepted (by most) style. On the other
hand, instances of “class” (or prototype), e.g., tesla, are often all lower case, because they kind of less
important, maybe? I beg your pardon, but I just can’t resist this joke when writing about classes:
5. Classes and Constructors 144
Why did the TypeScript programmer get kicked out of school? Because he refused to join any class
without a defined constructor!
As a result, it’s true that the advent of TypeScript has revolutionized JavaScript by bringing static
types and interfaces into the language. ES6 made classes mainstream as opposed to pure prototypal
inheritance (but I still like the function factories myself). As a developer, if you’re coming from
an object-oriented programming language like C# or Java, you’ll find TypeScript’s class syntax
strikingly familiar. But just because something looks familiar doesn’t mean it operates identically.
This is certainly true for TypeScript classes and constructors, a misunderstood concept with a few
idiosyncrasies that can lead to costly mistakes. One might presume that TypeScript, being a superset
of JavaScript, would adhere to the rules and limitations of JavaScript for Classes. This is generally
true, but TypeScript introduces additional features and checks that can catch developers off guard.
Even for experienced developers, the differences can cause confusion and lead to errors.
1 class User {
2 id: string;
3 email: string;
4 password: string;
5
6 constructor(id: string, email: string, password: string) {
7 this.id = id;
8 this.email = email;
9 this.password = password;
10 }
11 }
12
13 const newUser = new User("1", "Dima Ivanov", "123");
The newUser object has a type of User. In a way, TypeScript automagically created type User from
the class definition and then assigned type User to newUser when it saw the assignment operator
const newUser = new User(). When I mistakenly try to add a new property with newUser.isAdmin
= true;, I’ll get a TS problem “Property ‘isAdmin’ does not exist on type ‘User’.”
5. Classes and Constructors 145
This code is fine for a lot of use cases, but what will ensure that the class itself adheres to a certain
shape? Thus, the example is missing a crucial part - an interface. Without an interface, we are
losing TypeScript’s capability to enforce the shape of classes themselves. For example, if we have
a common interface for any users then we can create classes that implement the base interface
while adding additional functionality. This way we can be rest assured that the common parts are
implemented, and no one can accidentally miss a required property. By using an interface, we get
an additional layer of safety ensuring that our class adheres to a specific contract.
Here’s how we can improve the previous code by adding an interface IUser that is used for User.
And yes, before I wrote that prefixing with I is a bad tone in the TypeScript coding style but here
it’s helpful to see the difference between User class.
1 interface IUser { // #A
2 id: string;
3 email: string;
4 password: string;
5 }
6
7 class Editor implements IUser { // #B
8
9 id: string;
10 email: string;
11 password: string;
12 editRights: string[] = []; // #C
13
14 constructor(id: string, email: string, password: string) {
15 this.id = id;
16 this.email = email;
17 this.password = password;
18 }
19 }
20
21 class Viewer implements IUser { // #D
22
23 id: string;
24 email: string;
25 password: string;
26 viewRights: string[] = []; // #E
27
28 constructor(id: string, email: string, password: string) {
29 this.id = id;
30 this.email = email;
31 this.password = password;
5. Classes and Constructors 146
32 }
33 }
34
35 const viewer = new Viewer("1", "Rohan Gupta", "123456");
36 const editor = new Editor("2", "Priya Chatterjee", "p@ssword1");
Now, we have an IUser interface, and our Editor and Viewer classes implement this interface. This
means if we failed to implement any method or property from the interface in our class, TypeScript
would alert us with errors. Similarly, if one of the methods had a different signature or parameters,
we would receive an error. Conversely, if we were to add a new method or property to our class
that isn’t defined in the interface, TypeScript would be okay with it.
1 interface IUser {
2 id: string;
3 email: string;
4 password: string;
5
6 getPermissions(): string[];
7 }
8
9 class PasswordLessUser implements IUser { // #A
10 id: string;
11 email: string;
12 phone: string = ""; // #B
13
14 getPermissions() {
15 return "organizations:view"; // #C
16 }
17
18 constructor(id: string, email: string) {
19 this.id = id;
20 this.email = email;
21 }
22 }
Interestingly, we can also use interfaces (IUser) as types for objects instead of User class:
In this case, while the class can have additional properties like phone that are not in the interface
IUser, if we specify the type as IUser on newUser, the properties must match strictly. So, in the
following code, we will get an error “Property ‘phone’ does not exist on type ‘IUser’ ”:
Have you noticed that in Viewer and Editor we ended up duplicating a lot of code? Why not use a
base class to make code more eloquent and improve readability and maintenance?
1 interface IUser {
2 id: string;
3 email: string;
4 password: string;
5 }
6
7 class User implements IUser { // #A
8 id: string;
9 email: string;
10 password: string;
11
12 constructor(id: string, email: string, password: string) {
13 this.id = id;
14 this.email = email;
15 this.password = password;
16 }
17 }
18
19 class Viewer extends User {
20 viewRights: string[] = [];
21
22 addViewRight(right: string): void { // #B
23 this.viewRights.push(right);
24 console.log(`View right added: ${right}`);
25 }
5. Classes and Constructors 148
26 }
27
28 class Editor extends User {
29 editRights: string[] = [];
30
31 addEditRight(right: string): void { // #C
32
33 this.editRights.push(right);
34 console.log(`Edit right added: ${right}`);
35 }
36 }
37
38 const viewer = new Viewer("1", "Rohan Gupta", "123456");
39 const editor = new Editor("2", "Priya Chatterjee", "p@ssword1");
40
41 viewer.addViewRight("Read Articles"); // #D
42 editor.addEditRight("Modify Articles");
43
44 console.log(viewer);
45 console.log(editor);
#A Base class
#B Method to add a view right
#C Method to add an edit right
#D Example method calls
Last but not least, interfaces are cool in a way that they allow a class to conform (“implements”)
multiple interfaces while with the class inheritance (“extends”) we can only inherit from one class.
For example, we can enhance our example to have a class Contributor which of course needs to
have all the properties of a regular user, a viewer, and an editor (it would be hard to edit without
viewing):
1 interface IUser {
2 id: string;
3 email: string;
4 password: string;
5 }
6
7 interface IViewer {
8 viewRights: string[];
9 addViewRight(right: string): void;
10 }
11
5. Classes and Constructors 149
12 interface IEditor {
13 editRights: string[];
14 addEditRight(right: string): void;
15 }
16
17 class Contributor implements IUser, IViewer, IEditor { // #A
18
19 id: string;
20 email: string;
21 password: string;
22 viewRights: string[];
23 editRights: string[];
24
25 constructor(id: string, email: string, password: string) {
26 this.id = id;
27 this.email = email;
28 this.password = password;
29 this.viewRights = [];
30 this.editRights = [];
31 }
32
33 addViewRight(right: string): void {
34 this.viewRights.push(right);
35 console.log(`View right added: ${right}`);
36 }
37
38 addEditRight(right: string): void {
39 this.editRights.push(right);
40 console.log(`Edit right added: ${right}`);
41 }
42 }
43
44 const contributor = new Contributor( // #B
45 "3",
46 "[email protected]",
47 "secure123"
48 );
49
50 contributor.addViewRight("View Dashboard");
51 contributor.addEditRight("Edit Dashboard");
52
53 console.log(`Contributor ID: ${contributor.id}`); // #C
54 console.log(`Contributor Email: ${contributor.email}`);
5. Classes and Constructors 150
• Defining Contract for Classes: Interfaces in TypeScript are often used to define a contract that
a class must follow, specifying what methods and properties a class must implement. This is
useful in ensuring consistency especially when working with complex systems or large teams.
Thus, every time you have more than one piece of code (library, application, service) where
they try to talk to each other, it’s good to consider having a common ground between them by
enforcing contracts.
• Ensuring Consistency Among Similar Classes: If you have several classes that should share
the same structure, using an interface can enforce that they all implement the same properties
and methods. This is useful in scenarios where different classes represent different strategies
or types but should have the same basic capabilities.
• Decoupling Code: Interfaces help in decoupling the actual implementation of the class
from the interface it adheres to, especially with interfaces being shared among multiple
projects/libraries/applications as a separate package/module. This allows you to change the
implementation of a class without affecting any code that uses it, as long as the interface stays
the same. It’s especially useful in dependency injection and when developing software using
test-driven development (TDD).
• Enhancing Code Readability and Maintenance: By using interfaces, you make the code more
structured and easier to understand. Others can quickly see what the expected inputs and
outputs are, and what methods are available on objects of a particular type, without needing
to look at the full implementation details.
• Facilitating Code Reuse and Refactoring: With interfaces, you can design more generic and
reusable code components because the components depend on the interface, not on specific
implementations. This makes it easier to refactor code since changes in the implementation do
not impact interface compliance.
• Working with Libraries or Frameworks: Many JavaScript libraries and TypeScript frameworks
require or benefit from the use of interfaces to integrate with their systems. Interfaces can be
used to integrate custom classes with these libraries/frameworks more seamlessly.
• Polymorphism: Interfaces allow TypeScript classes to implement multiple interfaces while
classes can only inherit from one base class, thus providing a way to simulate multiple
inheritances and increase the flexibility of your code.
5. Classes and Constructors 151
This section covered multiple use cases around the usage of interfaces with classes. As you venture
into the world of TypeScript, remember the guidelines of when to use the interfaces to your
advantage to make your code safer, more reliable, and easier to understand. And contrary, don’t
fret skipping on interfaces in your TypeScript code (of course, as long as doing so does not lead to
unexpected bugs and errors.)
• Contain Implementation: Abstract classes can provide complete method implementations that
subclasses can inherit or override. They can also contain member variables.
• Support for Constructors: Abstract classes can have constructors, allowing more detailed
control over the initialization of new instances.
• Limited to Single Inheritance: Since TypeScript (and JavaScript) does not support multiple class
inheritance, a class can only extend one abstract class.
Example of an abstract class Animal that is used by a class Dog which in turn is used by a instance
Chewbarka (name of the dog):
12 }
13 }
14
15 const Chewbarka = new Dog();
16
17 Chewbarka.move(); // #B
18 Chewbarka.makeSound(); // #C
An interface in TypeScript, on the other hand, is purely a structural contract that defines the shape
of an object. Interfaces contain no implementation details themselves but define the properties and
methods that a class must implement.
• No Implementation: Interfaces strictly define what is to be done, not how it’s to be done. They
cannot contain any actual executable code.
• No Constructors: Interfaces cannot have constructors and therefore cannot dictate any specifics
about the initialization of an object.
• Support for Multiple Inheritance: Interfaces support multiple inheritance, allowing a class
to implement multiple interfaces, which is useful for defining functionalities from multiple
sources.
Example of interfaces Movable and SoundCapable that are used by the class Dog, which in turn is
used by an instance Pup Tart (name of the dog):
1 interface Movable {
2 move(): void;
3 }
4
5 interface SoundCapable {
6 makeSound(): void;
7 }
8
9 class Dog implements Movable, SoundCapable { // #A
10
11 move(): void {
12 console.log("Dog runs swiftly.");
13 }
14
15 makeSound(): void {
16 console.log("Woof");
5. Classes and Constructors 153
17 }
18 }
19
20 const PupTart = new Dog();
21 PupTart.move(); // #B
22 PupTart.makeSound(); // #C
The Dog class extends Animal and implements the Movable and SoundCapable interfaces. The Dog
class provides implementations of the move and makeSound methods.
All in all, in TypeScript, both abstract classes and interfaces are used to define contracts within your
code, but they serve different purposes and have different capabilities. And misunderstanding or
misusing abstract classes can lead to unnecessary complexity and confusion in your code.
One of the minor mistakes when working with abstract classes is trying to instantiate an abstract
class. It’s minor because we get a TypeScript warning. Namely, the last line in this code will throw
“Error: Cannot create an instance of an abstract class”:
In the above code, TypeScript will report an error as we’re trying to instantiate an abstract class.
Abstract classes are meant to be extended by other classes, not to be instantiated directly. To fix the
error, we need to define a Car class and instantiate that class instead of AbstractVehicle:
5. Classes and Constructors 154
#A Subclass needs to implement abstract method makeNoise but doesn't need to override
regular method start
#B The vehicle starts.
#C Vroom!
Another related mistake is forgetting to implement abstract methods in the child class, e.g.,
makeNoise:
#A Error: Non-abstract class 'Train' does not implement inherited abstract member
'brake' from class 'AbstractVehicle'.
5. Classes and Constructors 155
In the code above, we declare abstract methods makeNoise and brake in the AbstractVehicle class,
but forget to implement one of them in the Train class, which extends AbstractVehicle. TypeScript
will alert us about this omission because it breaks the contract established by the abstract class. To
fix this, we simply need to implement the brake method in the Train class:
Lastly, another subtle but critical mistake is to declare a constructor in an abstract class and forget
to call super() in the derived class constructor. This will result in a runtime error in addition to the
TypeScript one:
16
17 const SpeedyBeepBeep = new Car(); // #B
#A Forgot to call super() - this will cause a runtime error; Error: Constructors for
derived classes must contain a 'super' call.
#B Error: Must call super constructor in derived class before accessing 'this' or
returning from derived constructor
To fix the prior code, simply add a super call. We can even pass parameters to it:
Understanding and correctly using abstract classes in TypeScript can result in more robust, reusable,
and well-structured code. Ensure to instantiate only non-abstract (concrete) classes, always
implement all abstract methods in the derived classes, and remember to call super() when you define
constructors in the derived classes. This will help you avoid many headaches down the line.
As you saw previously, one of the key benefits of abstract classes is that they allow you to define
abstract methods along with regular ones. Abstract methods are methods that must be implemented
by any concrete subclass. However, one mistake is to define an abstract class without actually
defining any abstract methods, that is unnecessarily declare classes abstract. This can lead to
confusion and make it unclear why the class is abstract in the first place.
For example, consider the following abstract class Animal that has a property name and a
constructor:
5. Classes and Constructors 157
In this code, we define an abstract class Animal that has a single property name. However, we don’t
define any abstract methods that must be implemented by any concrete subclass. This makes it
unclear why the class is abstract in the first place. It’s better to either implement abstract methods
or not to have an abstract class and just use a normal class or an interface for example as follows:
1 class Animal {
2 name: string;
3 constructor(name: string) {
4 this.name = name;
5 }
6 }
7
8 class Dog extends Animal {
9 bark() {
10 console.log("Woof");
11 }
12 }
13
14 const MaryPuppins = new Dog("Mary Puppins");
15
16 MaryPuppins.bark();
When we began the section, we contracted interfaces and abstract classes. Logically, one other
mistake is using abstract classes when interface can suffice. Let’s take a look at an example of
misusing abstract classes. Consider the following code that has the same abstract class Animal, but
with an abstract method this time. We extend Animal two times:
5. Classes and Constructors 158
In this code, we define an abstract class Animal that has an abstract method makeSound(). We
then define two subclasses of Animal: Dog and Cat. Each subclass implements the makeSound()
method with a different sound. We also define a function makeAnimalSound() that takes an Animal
parameter and calls its makeSound() method. Finally, we create instances of the Dog and Cat classes
and pass them to the makeAnimalSound() function.
This code works and is kind of okay. Nevertheless, there’s a feeling that we created a suboptimal code
by using an abstract class in a way that is not necessary. Think about this. The Animal class provides
no implementation for the makeSound() method which is fine because it’s an abstract method. This
means that it is entirely up to the subclasses to provide their own implementation. Moreover, there
are no constructor nor any other regular non-abstract methods in the Animal class. This means
that the Animal class is essentially not very helpful on its own and only serves as a template for its
subclasses which we can do with interfaces too.
5. Classes and Constructors 159
In this particular case, instead of using an abstract class, we could simply define an interface that
defines the makeSound() method by replacing the abstract class with an interface:
1 interface Soundable { // #A
2 makeSound(): void;
3 }
4
5 class Dog implements Soundable { // #B
6 makeSound() {
7 console.log("Bark!");
8 }
9 }
10
11 class Cat implements Soundable { // #C
12 makeSound() {
13 console.log("Meow!");
14 }
15 }
16
17 class Car implements Soundable {
18 makeSound() {
19 console.log("Vroom!");
20 }
21 }
22
23 function makeSound(obj: Dog | Cat | Car) {
24 obj.makeSound();
25 }
26
27 const SirWaggington = new Dog();
28 const ChairmanMeow = new Cat();
29 const HonkASaurusRex = new Car();
30
31 makeAnimalSound(SirWaggington); // #D
32 makeAnimalSound(ChairmanMeow); // #E
33 makeSound(HonkASaurusRex); // #F
In this code, we define an Animal interface that defines the makeSound() method. We then define
the Dog and Cat classes as implementing the Animal interface, providing their own implementation
of the makeSound() method. We can then use the Animal interface as the parameter type of the
makeAnimalSound() function and pass instances of the Dog and Cat classes to it.
By and large, if “blueprint” functionality is very narrow cased and can be applied to different groups
of classes then interface is a good choice, because our Animal class had only one abstract method.
Now, if Animal has another functionality that is beneficial and used by subclasses, then abstract
class is a good fit because it’ll allow more code reuse and more eloquent code sharing.
Another common mistake is to overuse abstract classes when they aren’t really necessary. Abstract
classes are useful when you have a set of related classes that share some common behavior, but still
have unique implementation details. However, if you’re just defining a single class or a set of classes
that have completely different behavior, then abstract classes may not be the right tool for the job.
For example, consider the following code that has the abstract class Vehicle and the subclass Car
along with an unrelated to them class Bicycle:
27 }
28 }
In this code, we define an abstract class Vehicle that has two abstract methods drive and stop, as
well as two non-abstract methods accelerate and brake. We then define a concrete subclass Car
that implements the drive and stop methods. However, we also define a completely unrelated class
Bicycle that has its own unique methods. As a result, we can invoke four methods on Car and two
on bike:
In this case, using an abstract class may not be the best choice, as the Vehicle class doesn’t really
provide any shared behavior except brake, that both Car and Bicycle need to implement.
A good solution is to employ interfaces to ensure Bicycle and Car have consistent method signatures
where appropriate by using an interface Breakable (some TypeScript engineers prefer not to use I
in the name to differentiate between classes and types, because they use mostly interfaces and not
types).
5. Classes and Constructors 162
1 interface Breakable {
2 brake(): void;
3 }
4
5 class Car implements Breakable { // #A
6 drive() {
7 console.log("Driving...");
8 }
9 stop() {
10 console.log("Stopping...");
11 }
12 accelerate() {
13 console.log("Accelerating...");
14 }
15 brake() {
16 console.log("Braking...");
17 }
18 }
19
20 class Bicycle implements Breakable {
21 pedal() {
22 console.log("Pedaling...");
23 }
24 brake() {
25 console.log("Hand braking...");
26 }
27 // #B
28 }
The results are similar to the previous version of the code where there was no shared “contract” for
brake, but we simplified the code because it was hard to find overlap with abstract class Vehicle
between Car and Bicycle. The general rule here is that interfaces or abstract classes should have at
least two implementers or subclasses (respectively). Otherwise, using these interfaces and abstract
classes would be called a premature generalization.
In conclusion, abstract classes can be a useful tool in TypeScript because they can combine imple-
mentations and abstract contracts as we already saw prior. It’s surprising that many professional
and experienced TypeScript software engineers don’t even know about abstract classes let alone
can use them! Yet, abstract classes should be used judiciously and only when there is a need for
a common implementation or structure across a group of related classes. Misusing abstract classes
can lead to unnecessary complexity and confusion in your code.
5. Classes and Constructors 163
1 class Circle {
2 static PI = Math.PI; // #A
3
4 constructor(public radius: number) { // #B
5 }
6 calculateArea() {
7 return Circle.PI * this.radius * this.radius;
8 }
9 }
10
11 let circle = new Circle(5);
12
13 console.log(circle.calculateArea()); // #C
14 console.log(circle.radius); // #D
#A PI is about 3.14159...
#B Here we leverage public parameter property to define radius as a class
attribute/property
#C Log: 78.53975
#D Log: 5
In this example, PI is a static member of the Circle class. It’s a constant value that doesn’t change
between different circles, so it makes sense to make it a static member. Each Circle instance doesn’t
need its own copy of PI; they can all share the same value and this is the perfect use case for static
properties. On the other hand, radius is defined with the function parameter property as public
which allows access to radius as circle.radius.
Consequently, consider a mistake and misuse if we make radius a static member (class property):
5. Classes and Constructors 164
1 class Circle {
2 static PI = Math.PI;
3 static radius: number; // #A
4
5 constructor(radius: number) {
6 Circle.radius = radius;
7 }
8
9 calculateArea() {
10 return this.PI * Circle.radius * Circle.radius;
11 }
12 }
13
14 let circle1 = new Circle(5);
15 console.log(circle1.calculateArea()); // #B
16
17 let circle2 = new Circle(10);
18 console.log(circle2.calculateArea()); // #C
19 console.log(circle1.calculateArea()); // #D
In this example, radius is a static member, which means it’s shared between all instances of Circle.
When a new Circle is created, it overwrites the static radius value. This leads to unexpected results
when calculating the area of circle1 after creating circle2.
To avoid this mistake, remember that static members are shared between all instances of a class.
They should be used for values or methods that are related to the class as a whole, not to specific
instances. If a value or method relates to a specific instance, it should be an instance member, not a
static member. In the previous example, the correct way to design the Circle class is to make radius
an instance member:
1 class Circle {
2 static PI = Math.PI;
3 constructor(public radius: number) {}
4
5 calculateArea() {
6 return Circle.PI * this.radius * this.radius;
7 }
8 }
9
5. Classes and Constructors 165
Now, each Circle instance has its own radius value, and calculating the area of circle1 gives the
expected result, even after creating circle2.
Moreover, static can be combined with readonly, as in static readonly PI = Math.PI; More on other
modifiers in the next section. The Circle.PI is a great use case for that because it never changes.
Another use case would be default values, e.g., number of milliseconds before a timeout of a request,
number of open socket connections and so on. In the case of default values, you probably do want
to augment them.
And finally, the static modifier can be combined with a private modifier and even applied to methods
(functions). A good use case for static methods is getting a connection to a database or a service or
some other singleton-like structures, e.g.,
1 class DatabaseConfig {
2 private static readonly connectionString = "database-url";
3
4 public static getConnection() {
5 return DatabaseConfig.connectionString;
6 }
7 }
In conclusion, we move to the best practices for using static members/properties. To avoid the pitfalls
associated with inappropriate use of static members, follow these best practices:
• Use static members sparingly: Only use static members when it makes sense for the member
to be shared across all instances of the class, such as utility functions or constants.
• Avoid using static members for shared state: Instead, use instance members to maintain the
state of each instance separately. This can help you avoid issues related to global state, such as
unintended side effects and difficulties in testing and debugging.
• Name static members clearly and consistently: When using static members, make sure they’re
named clearly and used consistently throughout your codebase. This can help make your code
easier to understand and maintain. For read-only / constant properties, use UPPERCASE, e.g.,
static readonly PI = 3.14159;. For others, use very descriptive names, e.g., static defaultTimeout
= 3000;
5. Classes and Constructors 166
• Consider alternatives: If you find yourself using static members frequently, consider whether
there might be a better way to structure your code. For example, you could use a global or
module constant variable or a variable from a shared file/module/package.
Remember, static members are a powerful tool, but they should be used sparingly and thoughtfully.
Always consider whether a value or method should belong to the class as a whole or to specific
instances of the class.
• public: This is the default modifier, and it makes a member accessible from anywhere.
• private: This modifier makes a member accessible only from within the class that defines it.
• protected: This modifier makes a member accessible within the class that defines it and also
within subclasses.
Before we can jump into mistakes with access modifiers (and how to fix them), let’s quickly review
how and why use these three access modifiers with TypeScript classes. To illustrate the usage,
here’s an example of properly using all three access modifiers public, private, and protected in a
class Employee and its subclass Manager. The main idea and motive with these access modifiers is
to limit the access to the bare minimum to avoid conflicts, bugs and errors.
1 class Employee {
2 public name: string; // #A
3 private age: number;
4 protected position: string;
5
6 constructor(name: string, age: number, position: string) {
7 this.name = name;
8 this.age = age;
9 this.position = position;
10 }
11
12 protected getAge() {
13 return this.age;
14 }
5. Classes and Constructors 167
15 }
16
17 class Manager extends Employee {
18 constructor(name: string, age: number) {
19 super(name, age, "Manager");
20 }
21
22 public introduceYourself() { // #B
23 return `Hello, my name is ${this.name} and I work as a ${this.position}.`;
24 }
25
26 public revealAge() {
27 return `I am ${this.getAge()} years old.`; // #C
28 }
29 }
30
31 const manager = new Manager("Vladimir", 40);
32
33 console.log(manager.introduceYourself()); // #D
34 console.log(manager.revealAge()); // #E
In this example, name is a public property, age is a private property and position is a protected
property. The introduceYourself method is public, so it can be accessed from anywhere. The getAge
method is protected, so it can be accessed within the Employee class and its subclasses, but not
from outside these classes. Thus, the Manager subclass can use the getAge method in its revealAge
method. If we try to access and/or modify age and position, TypeScript will produce errors.
1 manager.age = 27; // #A
2 manager.position = "Janitor"; // #B
Just to remind about type and runtime errors, in IDEs and with tsc we’ll get errors, but when we
try to run the generated JavaScript code, it lets us access and modify age and position without
complaints, since the access modifiers are stripped out as part of the compilation process.
5. Classes and Constructors 168
Now that we refreshed how access modifiers are used properly, we can cover mistakes with them.
Indeed, several mistakes can occur when using access modifiers incorrectly, leading to bugs, security
issues, and code that is hard to maintain such as excessive use of public members, not using private
for internal state, and incorrect use of protected.
Let’s start with the more benign mistake of the excessive use of public members. If everything is
public, it’s hard to know what’s safe to change and what isn’t. It could also enable other parts of
your code to accidentally (or intentionally) misuse a class, by accessing properties or methods that
weren’t meant to be used externally. Basically, the whole purpose of having these access modifiers
is to limit the access on as needed basis (minimal as possible). Here’s a bad example with too many
public properties:
1 class User {
2 public id: number;
3 public password: string;
4 public email: string;
5 //...
6 }
If everything is public, it’s hard to know what’s safe to change and what isn’t. It could also enable
other parts of your code to accidentally (or intentionally) misuse a class, by accessing properties or
methods that weren’t meant to be used externally.
You may be wondering why write word public if it’s the default in TypeScript? In other words, the
previous code is equivalent to the following (equivalent functionally but not in readability):
1 class User {
2 id: number;
3 password: string;
4 email: string;
5 //...
6 }
The main argument to write public is that sometimes it’s good to make it explicit to let others know
that this property or method is intended for external (by other modules or files) use. Now, going
back to our mistake. To improve it, we can “hide” password by making it private and at the same
time implement and expose a method to validate the password:
5. Classes and Constructors 169
1 class User {
2 id: number;
3 private email: string;
4 public setEmail(email: string): boolean {
5 const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,6}$/;
6 if (!emailRegex.test(email)) return false;
7 this.email = email;
8 return true;
9 }
10 private password: string;
11 public validatePassword(password: string): boolean {...}
12 //...
13 }
In many other more OOP languages like Java, the modus operandi is to create pairs of setters and
getters for each private property. This has an added benefit of being able to execute some additional
logic before changing or viewing a property value, like validation. That’s considered a good style in
those languages. But in JavaScript/TypeScript, getters and setters never got hold and didn’t become
idiomatic… for good or bad.
If a class has properties or methods that should not be accessed or modified from outside the class,
they should be declared as private. Not doing this may lead to unexpected behavior or errors. Here
is a problematic example that uses public for internal count:
1 class Counter {
2 public count: number = 0;
3 public increment() {
4 this.count++;
5 }
6 }
It’s problematic because other code can modify count by some arbitrary number or increment it
incorrectly by 2 or 10 instead of 1.
1 class Counter {
2 public count: number = 0;
3 public increment() {
4 this.count++;
5 }
6 }
7
8 const CountasticStation = new Counter();
9 CountasticStation.increment(); // #A
5. Classes and Constructors 170
10
11 console.log(CountasticStation.count); // #B
12
13 CountasticStation.count = 100; // #C
14 console.log(CountasticStation.count); // #D
15
16 CountasticStation.count = -5; // #E
17 console.log(CountasticStation.count); // #F
18
19 CountasticStation.count = 0; // #G
20 console.log(CountasticStation.count); // #H
#A Intended use
#B Output: 1
#C Unintended modification
#D Output: 100
#E Unintended negative or invalid state
#F Output: -5
#G Accidental reset
#H Output: 0
Here’s a better version of the code that makes count private and exposes a getter. Note that there’s
not setter here, just increment:
1 class Counter {
2 private count: number = 0;
3 public increment() {
4 this.count++;
5 }
6 public getCount() {
7 return this.count;
8 }
9 }
10
11 const CountasticStation = new Counter();
12
13 CountasticStation.increment(); // #A
14 console.log(CountasticStation.getCount()); // #B
15 CountasticStation.count = 100; // #C
#A Intended use
#B Output: 1
#C Attempt to directly modify 'count' will result in an error: Property 'count' is
private and only accessible within class 'Counter'.
5. Classes and Constructors 171
If a class has properties or methods that should not be accessed or modified from outside the class,
they should be declared as private. Not doing this may lead to unexpected behavior or errors.
This leads us to the biggest gotcha with private (and other access modifiers). Namely, they are just
TypeScript constructs, not JavaScript constructs. This means that these access modifiers are checked
in IDEs and at compile time. And they are not enforced at all at runtime, because, in the generated
JavaScript that is run, these modifiers are being stripped out. To mitigate and really enforce at
runtime, consider using #prop ECMAScript/JavaScript private class property/field:
1 class Counter {
2 #count = 0; // #A
3 increment() {
4 this.#count++;
5 }
6 getCount() {
7 return this.#count;
8 }
9 }
10
11 const CountasticStation = new Counter();
12
13 CountasticStation.increment(); // #B
14 console.log(CountasticStation.getCount()); // #C
15 console.log(CountasticStation.#count); // #D
16 CountasticStation.#count = 5; // #E
#A Private field
#B Proper usage to increment the counter
#C Output: 1
#D Improper attempt to directly access or modify the private '#count' field results in
an error: Property '#count' is not accessible outside class 'Counter' because it has a
private identifier.
#E Incorrect access leads to the same error: Property '#count' is not accessible outside
class 'Counter' because it has a private identifier.
Lastly, the outdated style to prefix private members with an underscore _, that some of you may
still encounter in legacy code, is not really doing anything neither the TypeScript or ECMAScript/-
JavaScript level. It’s just a syntax convention.
Following this, we move to the incorrect and correct use of the protected access modifier. Protected
members are meant to be accessed by subclasses. If a protected member is not intended for use in
a subclass, it should be private. Here’s a bad example with protected age. The last line will report
(show) an error “Error: Property ‘age’ is protected and only accessible within class ‘Animal’ and its
subclasses.”:
5. Classes and Constructors 172
1 class Animal {
2 protected age: number; // #A
3 constructor(age: number) {
4 this.age = age;
5 }
6 }
7
8 const cat = new Animal(2);
9
10 console.log(cat.age); // #B
11 cat.age; // #C
#A Define a protected property age that can be accessed only by this class and its
children (subclasses that extend this class)
#B Improper use shows an error because age is protected
#C Also, improper use error: Property 'age' is protected and only accessible within
class 'Animal' and its subclasses.
To fix the cat.age access error, we can use the protected property properly by creating a new class
Cat that is subclass of Animal:
1 class Animal {
2 protected age: number;
3 constructor(age: number) {
4 this.age = age;
5 }
6 }
7
8 class Cat extends Animal {
9 constructor(age: number) {
10 super(age);
11 console.log(this.age); // #A
12 }
13
14 getAge(): number { // #B
15 return this.age;
16 }
17 }
18
19 const MeowlyCyrus = new Cat(1);
20
21 console.log(MeowlyCyrus.getAge()); // #C
22 MeowlyCyrus.age = 2; // #D
23 console.log(MeowlyCyrus.age); // #E
5. Classes and Constructors 173
Protected members are meant to be accessed by subclasses. If a protected member is not intended
for use in a subclass, it should be private.
To avoid these mistakes, follow these best practices:
• Favor private members: Unless a class member needs to be accessed from outside the class or
within a subclass, it should be declared as private. This encapsulates the internal workings of
your class and protects it from external interference.
• Use protected sparingly: Only use protected when you specifically want to allow access to a
member from a subclass. When writing a class, think how it will be extended by subclasses (if
any). If a member/class property is not intended for use in subclasses, make it private.
• Limit the number of public members: The fewer public members a class has, the easier it is to
understand how to use it correctly, and the less likely it is that the class will be misused.
• Avoid thinking that TypeScript access modifiers protect at runtime: The modifiers are absent at
runtime. Thus, consider using #prop ECMAScript/JavaScript private field to enforce at runtime.
In conclusion, access modifiers are a powerful tool for managing access to class members in
TypeScript, but incorrect usage can lead to issues. By understanding the purpose of each access
modifier and using them correctly, you can write more secure and maintainable code.
1 class MyClass {
2 myProperty: number;
3 constructor() {}
4 }
5
6 const myInstance = new MyClass();
7 console.log(myInstance.myProperty); // undefined
In this case, myProperty is declared but never initialized, so it defaults to undefined. If you later
attempt to perform operations assuming that myProperty is a number, you’ll run into runtime errors.
TypeScript has a —strictPropertyInitialization compiler option that ensures every instance property
of a class gets initialized in the constructor body, or by a property initializer. When this option is
enabled, TypeScript will generate a compile error if a class property is declared but not initialized.
Moreover, the highly recommended strict mode sets the aforementioned option strictPropertyIni-
tialization by default. (Yet another benefit of using the strict mode.) Let’s see the previous example
with this option enabled. TypeScript will give us “Error: Property ‘myProperty’ has no initializer
and is not definitely assigned in the constructor”:
1 class MyClass {
2 myProperty: number; // #A
3 constructor() {}
4 }
In this case, TypeScript generates an error, because myProperty is not initialized which helps us to
catch this type of bugs. What is interesting, TypeScript also has a counter-measure, sort of an escape
hatch, to the strict property initialization which we have to know about.
In TypeScript, the exclamation mark (“!”) postfix expression after a class property or variable name
is known as the definite assignment assertion operator. This operator is used to tell the TypeScript
compiler that the property has been initialized elsewhere, and thus, it can safely assume that the
property will not be null or undefined at runtime. This is particularly useful in scenarios where
class properties are initialized outside of the constructor, but TypeScript’s analysis cannot infer this
initialization.
Yes, yes. It may seem contrary to all the things I’ve just written about on how we should initialize
properties. But please bear with me and I’ll provide a few use cases of the purpose of this definite
assignment assertion operator to resolve the paradox.
The definite assignment assertion operator is especially useful in cases where you are integrating
TypeScript with libraries or frameworks that initialize properties in a way that TypeScript isn’t aware
5. Classes and Constructors 175
of (like dependency injection used in Angular), or in a scenario where the initialization occurs in
some method called within the constructor but not directly within it.
Here’s an example to illustrate how “!” is used in a TypeScript class. In this example, the fullName
and age properties are declared with “!”. This tells TypeScript that these properties will definitely be
assigned values before they are used. Even though properties are initialized in the initialize method
that is called by the constructor, TypeScript doesn’t know that. Without the “!” operator, TypeScript
would complain that these might be left uninitialized.
1 class UserProfile {
2 fullName!: string; // #A
3 age!: number; // #B
4 constructor(data: { name: string; age: number }) {
5 this.initialize(data);
6 }
7 initialize(data: { name: string; age: number }): void {
8 this.fullName = data.name; // #C
9 this.age = data.age;
10 }
11 }
12
13 const userProfile = new UserProfile({
14 name: "Jean Dupont",
15 age: 30,
16 });
17
18 console.log(userProfile.fullName); // #D
19 console.log(userProfile.age); // #E
• Initialize properties in the constructor: The constructor method of a class is the first method
that’s run when a new instance of the class is created. You can ensure that all properties are
initialized by assigning them initial values in the constructor, using parameters to constructor
with default values, e.g.,
1 class MyClass {
2 myProperty: number;
3 constructor(myProperty: number = 0) {
4 this.myProperty = myProperty;
5 }
6 }
• Use property initializers: TypeScript allows you to initialize properties at the point of
declaration, e.g.,
1 class MyClass {
2 myProperty: number = 0;
3 }
However, it’s just a TypeScript syntactic sugar for setting an initial property which turns into
assignment in the constructor in the generated JavaScript code:
1 class MyClass {
2 constructor() {
3 this.myProperty = 0;
4 }
5 }
• Leverage TypeScript’s strict mode: Enable the strict mode or just the strictPropertyInitialization
compiler option. This will ensure that you and other developers working on the projects will
be warned that some class properties are not initialized.
• Use the definite assignment assertion operator “!” when you are certain that the property will
be initialized before any access to it, and/or you are initializing it in a place that TypeScript
does not recognize as a valid initialization point (such as some lifecycle methods in frameworks
or libraries).
In conclusion, not initializing class properties is a common mistake that can lead to unpredictable
behavior and runtime errors. By initializing properties when they’re declared or in the constructor,
and by enabling TypeScript’s strictPropertyInitialization option, you can write more robust and
predictable TypeScript code.
5. Classes and Constructors 177
1 class MyClass {
2 toString() {
3 return {
4 message: "This won't work as expected",
5 };
6 }
7 }
8
9 const instance = new MyClass();
10 console.log(instance.toString()); // #A
It’s problematic mainly because if there are other downstream usages of toString that rely on the
original return type, we’ll have errors. And how would downstream code know that the toString
return type has been changed? Thus, a better version would use proper (original) return type and
will work as expected in downstream code:
5. Classes and Constructors 178
1 class MyClass {
2 toString() {
3 return "This will work as expected";
4 }
5 }
6
7 const instance = new MyClass();
8 console.log(instance.toString()); // #A
Next, we have a more complex example of a mistyped return that will have a runtime error (Object
Object) and not TypeScript errors:
1 class Money {
2 constructor(public amount: number, public currency: string) {}
3
4 toString(): { amount: number; currency: string } { // #A
5 return { amount: this.amount, currency: this.currency };
6 }
7 }
8
9 const cost = new Money(100, "USD");
10
11 console.log(cost.toString()); // #B
12
13 function displayPrice(price: Money): string { // #C
14
15 return `The price is: ${price.toString()}`;
16 }
17
18 console.log(displayPrice(cost)); // #D
In the above code, we have a Money class that represents a monetary value and currency. We
might think about overriding toString() to return an object instead of a string, which could cause
unexpected behavior in operations that expect a string representation.
To avoid such issues, ensure that toString() always returns a string. If you need a method to get the
object representation, create a separate method for that purpose. In this corrected example, toString()
5. Classes and Constructors 179
properly returns a string, and a separate toObject() method is provided for when the object format
is necessary. This maintains compatibility with any JavaScript environment or library expectations,
thereby avoiding the aforementioned issues.
1 class Money {
2 constructor(
3 public amount: number,
4 public currency: string) {
5 // ...
6 }
7
8 toString(): string { // #A
9 return `${this.amount} ${this.currency}`;
10 }
11
12 toObject(): { // #B
13 amount: number;
14 currency: string
15 } {
16 return {
17 amount: this.amount,
18 currency: this.currency
19 };
20 }
21 }
22
23 const cost = new Money(100, "USD");
24 console.log(cost.toString()); // #C
25
26 function displayPrice(price: Money): string {
27 return `The price is: ${price.toString()}`;
28 }
29
30 console.log(displayPrice(cost)); // #D
It’s worth mentioning that the signature of the overriding method must be the same. For example, if
we want to add an additional parameter to Array’s push, TypeScript will warn us of incompatibility
because the original push expect …items: number[] as params:
5. Classes and Constructors 180
To fix the example, we can use a new property log and bring the signature to the original one:
#A Enabling logs
#B Correct output: Adding 10 to the array
5. Classes and Constructors 181
When you need to override methods, ensure to preserve the expected return types and original
function signatures as much as possible. If you need to extend or modify the behavior of these
methods significantly, consider creating a new method with a different name instead to avoid
confusion and potential bugs.
Following this, the next potential problematic issue when overriding methods is not calling the
parent method with super.methodName(). In some (and maybe even most) cases, the overriding
method should call the original method to preserve its behavior. For constructors, TypeScript will
caution us that we forgot to call super with “Constructors for derived classes must contain a ‘super’
call.”, but for other methods TypeScript will be silent and we need to be vigilant. Here’s a broken
example of an array that is not calling super.push():
#A Adding value: 1
#B Output: 0 but should be 1
#A Adding value: 1
#B Output: 1 as it should be
1 class Base {
2 init() {
3 console.log("Base initialization");
4 }
5 }
6
7 class Derived extends Base {
8 init() { // #A
9 console.log("Derived initialization");
10 }
11 }
12
13 const obj = new Derived();
14 obj.init();
To fix this code, we need to invoke super.init() in the Derived class’s init method. Of course, not
always we need to call super.method(), that is when we don’t need any of the functionality of the
parent class methods but still want to override for other reasons like having other code that invoked
the same method name. In this case without super.method() there’s nothing to warn us that the
parent method is gone. Hence, a related error is when someone modifies a base class and then the
derived class has just a method not an override. We can see a modified getGreeting method in the
base class and TypeScript will be silent here:
1 class Base {
2 getGreeting() {
3 console.log("Hello from Base");
4 }
5 }
6
7 class Derived extends Base {
8 greet() {
9 console.log("Hello from Derived");
10 }
11 }
12
13 const derived = new Derived();
14
15 derived.greet(); // #A
16 derived.getGreeting(); // #B
5. Classes and Constructors 183
To mitigate this and help us, script wizards, TypeScript has the override keyword (as of version
4.3). It marks an override as override and warns if a base method is absent even without a call to
super.method:
1 class Base {
2 getGreeting() {
3 console.log("Hello from Base");
4 }
5 }
6
7 class Derived extends Base {
8 override greet() { // #A
9 console.log("Hello from Derived");
10 }
11 }
#A This member cannot have an 'override' modifier because it is not declared in the base
class 'Base'.
This is a significant improvement, but it’s not helpful if you neglect to use the override annotation
on a method—-this is another major error that users can encounter. For instance, you might
inadvertently overwrite an existing method from a base class without realizing it by using the same
name. It’s especially likely with common names such as init, config, validate, update, render, handle
and so on. Maybe you just wanted to create a helper method but ended up with an override?
This is the reason that TypeScript has noImplicitOverride flag. When this feature is enabled,
overriding any method from a superclass without explicitly using the override keyword results in
an error. As an illustration, in the scenario described earlier, TypeScript would produce an error
under noImplicitOverride, signaling that we likely need to rename our method in the Derived class:
1 class Base {
2 greet() {
3 console.log("Hello from Base");
4 }
5 }
6
7 class Derived extends Base {
8 greet() { // #A
9 console.log("Hello from Derived");
10 }
11 }
5. Classes and Constructors 184
#A This member must have an 'override' modifier because it overrides a member in the
base class 'Base'
• Understand the original method: Before you override a method, make sure you understand
what it does and what its return type is.
• Maintain the original contract: The overriding method should accept the same parameters and
return the same type as the original method.
• Call the parent method if necessary: If the original method has behavior that you need to
preserve, make sure to call it using super.
• Use noImplicitOverride flag to mark override explicitly to help track any further changes that
can cause bugs.
By following these best practices, you can avoid common mistakes when overriding built-in methods
and ensure your TypeScript code behaves as expected.
1 class AccessControl {
2 private _accessLevel: number;
3
4 constructor(accessLevel: number) {
5 this.setAccessLevel(accessLevel);
6 }
7
8 getAccessLevel(): number { // #A
9 return this._accessLevel;
10 }
11
12 setAccessLevel(value: number): void { // #B
5. Classes and Constructors 185
#A Getter
#B Setter
#C Output: 3
#D Property '_accessLevel' is private and only accessible within class 'AccessControl'.
#E Throws a runtime error because 6 is not supported.
1 class AccessControl {
2 private _accessLevel: number;
3
4 constructor(accessLevel: number) {
5 this.accessLevel = accessLevel; // #A
6 }
7
8 get accessLevel(): number { // #B
9 return this._accessLevel;
10 }
11
12 set accessLevel(value: number) { // #C
13 if (value >= 1 && value <= 5) {
14 this._accessLevel = value;
15 } else {
5. Classes and Constructors 186
The key differences between these two approaches are method names and syntax. In regards to
method names, in the first example (getAccessLevel and setAccessLevel), the getter and setter are
essentially regular methods named according to their purpose. It’s just a naming convention. The
names can be anything, e.g., updateAccessLevel instead of setAccessLevel. In the second example
(get and set), they are defined using TypeScript/JavaScript’s built-in syntax which makes them
accessible as if they were public properties, but with additional control over their assignment and
retrieval. And as far as syntax, the get and set syntax (second example) allows properties to be
accessed and modified using straightforward property notation (user.name), which can be more
intuitive and in line with accessing regular class properties.
Both approaches are valid and widely used. The choice between them often depends on coding
standards or personal preference. The get and set syntax is more idiomatic in modern TypeScript/-
JavaScript and other languages like C#. The syntax getName and setName are popular in Java.
Now that we refreshed our knowledge on the usage of setters and getters, let’s cover some mistakes
that we, lambda lords, make when using getters and setters: inconsistent types, inconsistent
validation, and having a getter without a setter (and vice versa).
One such mistake is having a getter and setter for the same property return but accepting different
types. Here we define a class with a getter and setter that use different types but there are not
TypeScript errors:
5. Classes and Constructors 187
1 class MyClass {
2 private _prop: number = 0;
3
4 get prop(): string {
5 return this._prop.toString();
6 }
7
8 set prop(value: number) {
9 this._prop = value;
10 }
11 }
12
13 let instance = new MyClass();
14 instance.prop = 10; // #A
15 console.log(instance.prop); // #B
#A OK
#B '10' as a string, but it's expected to be a number
In the above example, the setter accepts a number, but the getter returns a string. This can lead to
confusion and type-related errors in your code. A better code would be using just one type number:
1 class MyClass {
2 private _prop: number = 0;
3
4 get prop(): number {
5 return this._prop;
6 }
7
8 set prop(value: number) {
9 this._prop = value;
10 }
11 }
12
13 let instance = new MyClass();
14 instance.prop = 10;
15 console.log(instance.prop);
Now, when you set instance.prop to 10, it will store and return the number 10, as expected. The
getter and setter methods for prop are consistent, and _prop will only ever store a number, not a
string or a number. If you ever need a string, you can convert it with instance.prop.toString().
In this example, the value getter returns a number, but the value setter accepts any type. This can
lead to unintended behavior if a non-numeric value is passed to the setter:
5. Classes and Constructors 188
1 class MyClass {
2 private _prop: number;
3
4 constructor(value: number) {
5 this._prop = value;
6 }
7
8 get prop(): number {
9 return this._prop;
10 }
11
12 set prop(value: any) {
13 this._prop = Number(value);
14 }
15 }
The issue with this code is that the setter for prop accepts any type, which is not ideal. Using any
disables TypeScript’s static type checking, leading to potential runtime errors. The better approach
would be to enforce the correct type in the setter. If you intend to allow the value to be set using a
string, consider using a union type instead (number | string).
Here’s the improved code of MyClass that has proper handling of types in set prop():
1 class MyClass {
2 private _prop: number;
3
4 constructor(value: number) {
5 this._prop = value;
6 }
7
8 get prop(): number {
9 return this._prop;
10 }
11
12 set prop(value: number | string) { // #A
13 if (typeof value === "string") {
14 const parsed = parseFloat(value);
15 if (isNaN(parsed)) {
16 throw new Error(
17 "Value must be a valid number or a string that can be converted to a numbe\
18 r."
19 );
20 }
5. Classes and Constructors 189
21 this._prop = parsed;
22 } else {
23 this._prop = value;
24 }
25 }
26 }
In the updated code, the setter now accepts both numbers and strings. If a string is passed, it tries to
parse it into a number using parseFloat. If the string cannot be converted into a number (i.e., parsed
is NaN), it throws an error. This way, we can ensure that _prop will always hold a number, while
still allowing some flexibility in the setter.
Remember, using the any type defeats the purpose of TypeScript’s static type checking. Avoid using
it whenever possible. Instead, use more specific types, including union types, to ensure the validity
and reliability of your code.
In the next example, the setter only allows positive numbers, but there is no such validation in the
constructor. This can lead to unexpected behavior and bugs.
1 class MyClass {
2 private _prop: number;
3
4 constructor(value: number) {
5 this._prop = value;
6 }
7
8 get prop(): number {
9 return this._prop;
10 }
11
12 set prop(value: number) {
13 if (value > 0) {
14 this._prop = value;
15 }
16 }
17 }
Also, it can potentially lead to confusion because an attempt to set prop to a non-positive number
will fail silently. This is generally not considered good practice as it could lead to difficult-to-debug
issues. A better approach might be to throw an error when an invalid value is provided and has a
validation check in the constructor. This gives immediate feedback about the incorrect usage and
helps avoid silent failures:
5. Classes and Constructors 190
1 class MyClass {
2 private _prop: number;
3
4 constructor(value: number) {
5 if (value <= 0) {
6 throw new Error("Value must be greater than zero.");
7 }
8 this._prop = value;
9 }
10
11 get prop(): number {
12 return this._prop;
13 }
14
15 set prop(value: number) {
16 if (value <= 0) {
17 throw new Error("Value must be greater than zero.");
18 }
19 this._prop = value;
20 }
21 }
In the updated code, the constructor and setter now both throw an error if an attempt is made to
set prop to a non-positive number. This helps ensure that instances of MyClass always maintain
a positive prop, and any attempt to violate this rule will be immediately flagged. As always, the
specific improvements you should make depend on your use case and the requirements of your
program.
Another related mistake is to define a getter for a property without a corresponding setter, or a setter
without a corresponding getter. This can lead to unexpected undefined values and makes the class
interface incomplete or inconsistent, unless you want to achieve immutability by having just getter
and not setter (but that’s a different topic that can also be achieved with readonly that we covered
before).
1 class MyClass {
2 private _prop: number = 0;
3
4 get prop(): number {
5 return this._prop;
6 }
7
8 // #A;
9 }
5. Classes and Constructors 191
10
11 let instance = new MyClass();
12 console.log(instance.prop); // #B
13 instance.prop = 10; // #C
#A No corresponding setter
#B Output: 0
#C Error: Cannot set property prop of #<MyClass> which has only a getter
The current MyClass has a prop getter but no corresponding setter. As a result, TypeScript throws
an error when you try to assign a value to prop. To solve this, you can add a setter for prop. Here’s
how you can do it:
1 class MyClass {
2 private _prop: number = 0;
3
4 get prop(): number {
5 return this._prop;
6 }
7
8 set prop(value: number) { // #A
9
10 if (typeof value !== "number") {
11 throw new Error("Value must be a number.");
12 }
13
14 this._prop = value;
15 }
16 }
17
18 let instance = new MyClass();
19 console.log(instance.prop); // #B
20 instance.prop = 10; // #C
21 console.log(instance.prop); // #D
In the updated code, the prop setter takes a value parameter of type number. The typeof check
ensures that value is a number, and if not, an error is thrown. If the value is a number, _prop is
updated. Now, you can both read and write prop as expected.
To avoid these issues with getters and setters, consider the following best practices:
5. Classes and Constructors 192
• Use get/set if you need to control or validate property access, add side effects, or calculate
derived values. They allow you to treat properties as fields while actually implementing them
with methods, maintaining a consistent way to access properties as if they were public. Also,
you can add logic to getters and setters without changing how the properties are accessed
externally.
• Use traditional getProp and setProp methods if coming from a Java background or when
needing methods that appear more method-like in nature, such as those needing multiple
parameters. Also, explicit method calls make it clear that there might be logic involved beyond
just accessing a field.
• Use public properties when you need simple, straightforward data containers without ad-
ditional logic, especially when simplicity and minimalism in code are more beneficial than
encapsulation and control. However, using public properties exposes the internal structure of
your class, making future changes that might require validation or other logic more difficult
and potentially breaking for existing users of your class.
• Ensure Consistent Types: The types accepted and returned by the getter and setter for a
property should be consistent.
• Always pair Getters and Setters: If a property has a setter, it should also have a corresponding
getter, and sometimes vice versa, if it has a getter, it should have a setter, unless one needs
immutability of sorts when only at instantiation (constructor) the value is set.
• Use Getters and Setters for Computed Properties: If a class has properties that are derived from
others, you can use getters and setters to compute their values on the fly, e.g., to read an area
of a rectangle (rect.area) we can multiply width and height properties, and not have the actual
area property, only its getter:
• Document constraints: If there are constraints on a property, such as allowed value ranges
or types, make sure to document these constraints in comments or through TypeScript’s type
system.
• Utilize setters for validation and do it consistently: If you need to validate a property value,
make sure to do so consistently in both the setter, in other methods and in the constructor, if
applicable.
5. Classes and Constructors 193
By understanding how getters and setters work and following these best practices, you can avoid
common mistakes and write more consistent, predictable TypeScript code. Using getters and setters
in TypeScript (and in JavaScript) helps you control how your data is accessed and manipulated. By
using them, you can add additional logic (like validation or transformation) when getting or setting a
property. Remember to provide both getter and setter for a property whenever necessary to prevent
errors like this.
1 class Bird {
2 fly(): string { // #A
3 return "Flying high";
4 }
5 }
6
7 class Penguin extends Bird {
8 fly(): string {
9 throw new Error("Cannot fly");
10 }
11 }
12
5. Classes and Constructors 194
#A Designing this parent class Bird we should not assume that all birds are flying and
avoid having fly method here
#B Output: Flying high
#C Throws Error: Cannot fly
While keeping the Liskov principle in mind, let’s move on to the actual mistake of now knowing
about the composition (and not using it when appropriate). To illustrate the mistake, we’ll explore
two examples, first with inheritance and the second with composition (which would be more
flexible and the recommended way). We’ll model an RPG-like game scenario with different types
of characters that can perform various actions, such as moving or attacking, by creating a class
hierarchy for different types of characters. We’ll have an abstract base class Character and all the
specific characters such as wizard, knight, dwarf and so on, will inherit from it. They will override
needed behaviors.
#A Base Character class with abstract methods (each character subclass must define their
own movement and attack methods)
#B Knight class inheriting from Character
#C Wizard class inheriting from Character
#D Wizards and knights can walk the same, hence duplicating the code.
#E Creating instances of each character
#F Output: Walking and Attacks with a sword
#G Output: Walking and Casts a magic spell
Character class is an abstract class that defines the template methods move() and attack() which
every subclass must implement. (For more details on abstract classes and methods, see previous
material in this chapter.) While Knight and Wizard classes are the subclasses that implement the
specific behaviors for movement and attack defined by their character types. It works but is not
as flexible as composition. For example, we can rewrite the previous code as this using interfaces
MoveBehavior and AttackBehavior and class properties for each type of behavior:
1 interface MoveBehavior { // #A
2 move(): string;
3 }
4
5 interface AttackBehavior {
6 attack(): string;
7 }
8
9 class WalkingBehavior implements MoveBehavior { // #B
10 move(): string {
11 return "Walking";
12 }
13 }
14
15 class FlyingBehavior implements MoveBehavior {
16 move(): string {
17 return "Flying";
5. Classes and Constructors 196
18 }
19 }
20
21 class SwordAttack implements AttackBehavior { // #C
22 attack(): string {
23 return "Attacks with a sword";
24 }
25 }
26
27 class MagicAttack implements AttackBehavior {
28 attack(): string {
29 return "Casts a magic spell";
30 }
31 }
32
33 class Character { // #D
34 private moveBehavior: MoveBehavior;
35 private attackBehavior: AttackBehavior;
36 constructor(moveBehavior: MoveBehavior, attackBehavior: AttackBehavior) {
37 this.moveBehavior = moveBehavior;
38 this.attackBehavior = attackBehavior;
39 }
40
41 performMove(): string {
42 return this.moveBehavior.move();
43 }
44
45 performAttack(): string {
46 return this.attackBehavior.attack();
47 }
48 setMoveBehavior(mb: MoveBehavior): void { // #E
49 this.moveBehavior = mb;
50 }
51 setAttackBehavior(ab: AttackBehavior): void {
52 this.attackBehavior = ab;
53 }
54 }
55
56 const knight = new Character(new WalkingBehavior(), new SwordAttack()); // #F
57 const wizard = new Character(new WalkingBehavior(), new MagicAttack());
58
59 console.log(`${knight.performMove()} and ${knight.performAttack()}`); // #G
60 console.log(`${wizard.performMove()} and ${wizard.performAttack()}`); // #I
5. Classes and Constructors 197
61 wizard.setMoveBehavior(new FlyingBehavior()); // #J
62 console.log(wizard.performMove()); // #K
Behavior interfaces (MoveBehavior, AttackBehavior) are interfaces that define the contracts for
movement and attack behaviors, respectively. The concrete behaviors (classes WalkingBehavior,
FlyingBehavior, SwordAttack, MagicAttack): are classes that implement the behavior interfaces
with specific (actual) actions. Finally, the Character is a class that is composed of moveBehavior
and attackBehavior. It does not inherit these behaviors but rather delegates the behavior to the
corresponding objects. This allows characters to change their behaviors at runtime (i.e., without
changing code), demonstrating flexibility over the first approach with just pure inheritance. This
composition example in TypeScript provides a clear illustration of how behaviors can be dynamically
combined and modified, which is a powerful advantage over traditional inheritance-based designs.
To sum up, the benefits of this approach are as follows:
• Flexibility: Behaviors can be changed at runtime as shown when the wizard starts flying.
In other words, new behaviors can be added without modifying existing classes. In pure
inheritance, adding new behaviors may require changes to the base class or the creation of
many subclasses, potentially leading to a bloated and deep class hierarchy.
• Reusability: Individual behaviors can be reused by different characters or other parts of the
application. Pure inheritance (first example) can lead to duplication of code (as seen with the
move method in both Knight and Wizard) unless carefully managed. Common behaviors need
to be either placed high in the class hierarchy or repeated in each subclass.
• Reduced Class Hierarchy Complexity: Avoids deep and potentially confusing inheritance
structures, especially when you start adding more and more character types and their
behaviors. (Initially pure inheritance can be simple to understand and it may be even more
eloquent but that’s just initially.)
In conclusion, the mistake is to always use inheritance even where composition is more appropriate
and flexible. This can lead to problems like tightly coupled classes, fragile code, and violation of the
Liskov Substitution Principle.
5. Classes and Constructors 198
1 class Employee {
2 firstDayAtWork!: Date;
3
4 constructor(
5 public name: string,
6 public jobTitle: string
7 ) {
8 this.jobTitle = jobTitle;
9 this.name = name;
10 await this.init(); // #A
11 }
12
13 async init() { // #B
14 await new Promise((resolve) => setTimeout(resolve, 1000));
15 this.firstDayAtWork = new Date();
16 console.log("Initialization complete");
17 }
18
19 greet() {
20 console.log(
21 `Hello, my name is ${this.name} and I work as a ${this.jobTitle} since ${this.\
22 firstDayAtWork}`
23 );
24 }
25 }
5. Classes and Constructors 199
26
27 const max = new Employee("Max Mustermann", "Software Developer");
28 max.greet();
In the above example, we’re trying to use await in the Employee constructor to wait for an
asynchronous initialization function. However, this is not valid and results in a TypeScript error:
‘await’ expressions are only allowed within async functions and at the top levels of modules. Adding
async in front of the constructor is not an option either because TypeScript will show: ‘async’
modifier cannot appear on a constructor declaration. And without async, we would not get the
firstDayAtWork in the greeting (imagine that the first day at work data is coming from a database
or an API, hence the delay and the need for async method). Having a promise this.init().then(r=>{})
instead of async would not work either because the constructor wouldn’t wait for it to finish.
So, then what to do if we still need to execute some async code to initialize the object? Well, we can
first instantiate (create the instance object) and then execute init and wait for the first day data in
the then body before calling greet. In this case we don’t need to use await in a constructor:
This is used in some libraries, e.g., database client libraries where first they create an instance and
then developers need to invoke “connect” method to asynchronously (no way around it, networks
are asynchronous in nature) establish the connection to the database server. While this approach
works, it’s not great because it requires developers to know about the need to call another method,
and I know for a fact that RTFM is still a thing.
The answer is that if you need to perform asynchronous operations when creating an instance of a
class, an interesting approach is to use a static factory method in the class. Factory basically means it
produces (pun intended) new instances of a class every time this factory method/function is invoked.
This method can be made async, perform the necessary asynchronous operations, and then return
the new instance. Here’s how you can rewrite the previous example correctly using a static factory
method to handle async operations:
5. Classes and Constructors 200
1 class Employee {
2 static async create( // #A
3 name: string,
4 jobTitle: string
5 ) {
6 const employee = new Employee(name, jobTitle);
7 await employee.init();
8 return employee;
9 }
10
11 firstDayAtWork!: Date;
12
13 private constructor( // #B
14 public name: string,
15 public jobTitle: string
16 ) {
17 this.jobTitle = jobTitle;
18 this.name = name;
19 }
20
21 private async init() { // #C
22 await new Promise((resolve) => setTimeout(resolve, 1000));
23 this.firstDayAtWork = new Date();
24 console.log("Initialization complete");
25 }
26
27 greet() {
28 console.log(
29 `Hello, my name is ${this.name} and I work as a ${this.jobTitle} since ${this.\
30 firstDayAtWork}`
31 );
32 }
33 }
34
35 Employee.create( // #D
36 "Max Mustermann",
37 "Software Developer"
38 ).then(
39 (max) => max.greet() // #E
40 );
#A Static method allows us to execute async code and returns the newly created object.
#B We can make constructor private because only static method should call it and no one
5. Classes and Constructors 201
else.
#C Simulating an async operation; we can make init private now.
#D Create an Employee instance asynchronously using the static factory method.
#E Wait until the instance is ready and only then invoke greet; the same code can be
rewritten with async/await.
In this corrected example, we’re using a static factory method Employee.create to create the
Employee instance. This method can be made async like any other normal class method (not
constructor), allowing us to wait for the init method before returning the new instance.
By understanding the synchronous nature of constructors and using static factory methods for
asynchronous operations, you can avoid confusion mistakes, and write async code for object
instantiations. By using the principle of least needed access, you can limit init and constructor
to private to ensure that only static factory methods will be called to instantiate the object.
Before we wrap up the section, it’s worth noting two related to constructor mistakes that would have
been really bad mistakes in JavaScript but not serious in TypeScript. This is because TypeScript will
warn us that we forgot or missed them. I’m talking about forgetting super in constructors of derived
(sub) classes and having wrong return type for constructor. In the first case, we’ll see “Constructors
for derived classes must contain a ‘super’ call.”. And in the second two errors: : “Return type of
constructor signature must be assignable to the instance type of the class”, and “Type ‘’ is missing
the following properties from type ‘Employee’: greet, name”.
1 class Person {
2 constructor(public name: string) {}
3
4 greet() {
5 console.log(`Hello, my name is ${this.name}`);
6 }
7 }
8
9 class Employee extends Person {
10 constructor( // #A
11 name: string,
12 public jobTitle: string
13 ) {
14 return { jobTitle: this.jobTitle }; // #B
15 }
16
17 greet() {
18 console.log(
19 `Hello, my name is ${this.name} and I work as a ${this.jobTitle}`
20 );
21 }
22 }
5. Classes and Constructors 202
23
24 const max = new Employee("Max Mustermann", "Software Developer");
25 max.greet();
This example clearly illustrates how amazing TypeScript static checking is. Without it, in plain
JavaScript it would have been extremely easy to make serious mistakes.
Throughout this section, we’ve delved into common mistakes developers make when dealing with
class constructors in TypeScript. We’ve seen how these mistakes can lead to unanticipated behavior
and bugs that are challenging to debug. However, with the knowledge and best practices shared in
this section, you are now better equipped to handle class constructors in TypeScript. By avoiding
these common pitfalls, you can write cleaner, more efficient, and bug-free TypeScript code. Keep
these tips in mind, and you’ll be well on your way to mastering TypeScript constructors.
• Class Decorators - Applied to the constructor of the class and can be used to observe, modify,
or replace a class definition.
• Method Decorators - Applied to the property descriptor of the method of a class, and can be
used to observe, modify, or replace method definitions.
• Field Decorators - Applied to the class field/property and can expose a private field, change the
values with which this field is being initialized but cannot change or replace the field directly.
5. Classes and Constructors 203
• Auto-Accessor Decorators - Applied to the property descriptor of the accessor and can observe,
modify, or replace accessor definitions, and allow more control over regular fields (when we
use accessor fields).
• Getters and Setter Decorators - Applied to the get or set methods of a class, they act similarly
to method decorators.
Let’s start with field decorators. Here’s an example showing a simple dependency injection with a
field decorator, akin to Angular or Java Spring Boot. We want to inject a logger dependency so that
we can log events in a class:
The actual implementations of the helper type and the registry as below:
5. Classes and Constructors 204
To continue with the logger theme, consider this example of a class App that connects to a database
and boots up a server to accept incoming connections on a given port number. This example is
without decorators yet.
#A Boot up a server
#B Connect to a database
While this example works perfectly fine, what if we need to debug it? We can manually add
console.log in listen and connectToDB but it would be a lot of manual work and not take advantage
of the powerful decorators which could easily make our job easier while making the class more
expressive and easier to read/understand. Hence, let’s quickly add a simple decorator for logging
5. Classes and Constructors 206
and debugging. We’ll use a @log decorator that logs whenever a method is called before and after
the original method’s code. Here’s how we define a decorator log:
Then, we shall apply the decorator to the already familiar App class (just showing the methods with
decorators and skipping other code):
1 @log
2 async listen(portNumber: number = this.portNumber) {
3 await sleep(100);
4 return `Listening for incoming requests on port: ${this.portNumber}`;
5 }
6
7 @log
8 async connectToDb(dbUrl: string) {
9 if (!dbUrl) throw new Error("Must provide DB URL");
10 await sleep(100);
11 return `Connected to DB on ${dbUrl}`;
12 }
In this example, we’ve applied the @log decorator to the class methods. Now, whenever these
methods are called, we also logs the method name and arguments to the console. This could be
useful for debugging or tracking purposes! Interestingly, we can pass parameters to decorators. In
this case the decorator implementation complicates a bit.
5. Classes and Constructors 207
44 console.log(await app.connectToDb("localhost:27027/my_db"));
45 console.log(await app.listen(8080));
46 })();
The output would be like as follows (lines not explicitly called out as red or blue will be standard
black):
#A Blue color
#B Blue color
#C Red color
#D Red color
Lastly, we can specify decorator names in multiple ways, not just with a simple one word name. All
of these are valid decorator names for a class:
1 @BasicDecorator
2 @DecoratorWithArgs("param1", "param2")
3 @LibraryComponent.property
4 @ExternalService.process(123)
5 @WrapExpression(Storage["key"]) // arbitrary expression
6 class ExampleClass {
7 //...
8 }
• @BasicDecorator is just a basic type of decorator not unlike we’ve seen in the first examples.
It’s not taking any arguments.
5. Classes and Constructors 209
Next, let’s see an example with a method decorator to bind the context of a method. This is very
common in React and a lot of other browser related code where context is different between where
the code is written and when it’s executed. Basically, if we don’t bind, we can’t call a method after
assigning it to a function or in a different context. It’s a common mistake of “Cannot read properties
of undefined”, because the context (this) will be undefined or different from our class. Or if we mark
a property private, we won’t be able to access it either.
1 class ColorDescriptor {
2 #colorName: string;
3
4 constructor(colorName: string) {
5 this.#colorName = colorName;
6 }
7
8 public toString(): string {
9 return `Color(${this.#colorName})`;
10 }
11 }
12
13 const pinkColor: ColorDescriptor = new ColorDescriptor("pink");
14 console.log(pinkColor.toString());
15
16 const getPinkColorString = pinkColor.toString;
17 console.log(getPinkColorString());
18
19 setTimeout(() => console.log(getPinkColorString()), 0);
The good old way to fix this is to add the bind method:
5. Classes and Constructors 210
But this is a section on decorators, so how can we optimize our code to make it more elegant? We
define the decorator function bindContext and then annotate toString with it:
1 function bindContext(
2 originalMethod: any,
3 descriptor: ClassMethodDecoratorContext
4 ): void {
5 descriptor.addInitializer(function () {
6 this[descriptor.name] = originalMethod.bind(this);
7 });
8 }
9
10 class ColorDescriptor {
11 #colorName: string;
12
13 constructor(colorName: string) {
14 this.#colorName = colorName;
15 }
16
17 @bindContext
18 public toString(): string {
19 return `Color(${this.#colorName})`;
20 }
21 }
22
23 const pinkColor: ColorDescriptor = new ColorDescriptor("pink");
24 console.log(pinkColor.toString());
25
26 const getPinkColorString = pinkColor.toString;
27 console.log(getPinkColorString()); // #A
28 setTimeout(() => console.log(getPinkColorString()), 0); // #B
There’s much more to decorators (getters, setters, class, auto-accessors) but I hope this section and
its example gave you enough of a taste of the TypeScript decorators and eliminated any doubt or
fear on how to read them in existing code and apply to your own code. We also used a private
property syntax, so now we saw both the “at” sign (@) and the hashtag sign (#) and know what they
mean. Isn’t it cool to see and use them in TypeScript!? I’m still excited about them as valid syntax,
because in good old JavaScript that I started with they didn’t exist!
5. Classes and Constructors 211
In conclusion, decorators are a powerful part of TypeScript’s toolkit. Ignoring them completely could
mean missing out on a tool that can make your code cleaner and more expressive. However, like all
tools, they should be used judiciously and appropriately. With a solid understanding of decorators
and when to use them, you can write more effective TypeScript code.
5.11. Summary
• Implement interfaces when you need to enforce a certain structure on a class. They provide a
blueprint for the class, making your code more predictable and less error prone.
• Use abstract classes judiciously. They’re useful when there’s a need for a common implemen-
tation across a group of related classes, but misuse can lead to unnecessary complexity.
• Be aware of when to use static class members. They belong to the class itself, not instances of
the class.
• Understand and use correctly the TypeScript’s access modifiers: public, private, and protected.
These control the visibility of class properties and methods, aiding encapsulation.
• Try to initialize class properties or ensure they’re assigned a value in the constructor. Failing
to do so can lead to undefined property values and runtime errors. Know about the ! escape
hatch.
• Knowing how to use and override built-in methods can be useful but must be done carefully
to maintain original method signatures and return types.
• Ensure consistency in getter and setter methods in your classes.
• When subclassing, remember to call super() in the constructor (if needed). When overriding,
know to use a type enforcer.
• Keep in mind that decorators provide a way to add metadata and extra functionality to your
classes, methods, and properties.
6. Advanced Parts and Bad Parts of
TypeScript
This chapter covers
• Using generics
• Avoiding enums and tuples
• Applying type narrowing
• Avoiding pitfalls of asynchronous coding
Welcome to the labyrinthine world of TypeScript, where we navigate the twisty passages of
advanced features and dodge the occasional goblin of common pitfalls! This chapter, while serving
as your guide to the shadowy depths of generics, enums, tuples, and the mystical arts of async
coding, is not your average, dry technical manual. Instead, it promises the thrills of an adventure
novel, the chuckles of a late-night comedy show, and maybe, just maybe, the wisdom of a wise old
sage (who knows a lot about software).
Now, let’s crack the spine of this chapter with a bit of humor: Why do programmers prefer dark
mode? Because light attracts bugs! And speaking of bugs, TypeScript generics are like the bug spray
for your code. They keep the type-checking mosquitoes at bay, ensuring that your code is as reusable
and adaptable as a Swiss Army knife—without the risk of cutting yourself on edge cases.
Generics are the heroes of this tale, wielding the power of type safety like a sword that can morph
to match any enemy—er, data type. Imagine a function so versatile that it could handle any type
thrown into the mix; a shape-shifter that adapts to strings, numbers, or even complex objects. This
isn’t just fantasy—it’s the power of generics! For example, let’s talk about a simple, yet powerful
generic function:
Here, T is not just a letter, but a placeholder for any type you wish to pass. It’s like telling your
function, “Be prepared, something wild this way comes… but no worries, you got this!” You could
then summon this function into action with any type, from a humble string to a union of command-
line user roles:
6. Advanced Parts and Bad Parts of TypeScript 213
Moving on from the battlegrounds of generics to the shady corners of avoiding enums and tuples—
sometimes, it’s not about what you use, but what you wisely choose to avoid. While enums and
tuples have their place, like that old sword you keep above the fireplace, there are times when simpler
constructs such as union types might serve you better, offering clarity without the overhead.
And what about async coding? It’s like the dark arts of the programming world; powerful, yet
fraught with peril if not handled with care. We’ve all faced the dreaded beast of unhandled promise
rejections, a common blunder that can crash your application like a poorly constructed spell. Here’s
how you might invoke an async function with all the due precautions:
As we tread further into this chapter, remember: the path of a TypeScript developer is filled with
choices. Choose wisely, padawan, whether you’re summoning generics into existence, opting out
of enums, or dabbling in the async-await incantations. May the type safety be with you, and may
your console.log always return true. So buckle up, get your coding gloves on (or wrist pads), and
let’s drive into the arcane world of TypeScript’s advanced parts and the bad parts you ought to
avoid—lest you want your code haunted by the ghosts of past bugs.
For example, consider an array. An array can hold elements of any type, but once you create an
instance of an array, you’d want it to work with a specific type, like string or number. Here’s how
you might define a simple generic function:
In this example, T is our type variable. We use it as a placeholder for whatever type will be passed to
the identity function when it’s called. It’s a way of saying, “I don’t know what type we’ll be working
with, but I promise it will be consistent.”
You could then use the function with a specific type, like so with the string type:
In this case, we’re specifying that we want to use the identity function with the type string.
TypeScript will then enforce that the argument and return value are of type string.
It’s worth noting that in real life (and code), it’s unusual to specify explicitly type parameters
like identity<string>("myString"). If we just write, identity("myString"), TypeScript
will infer the type T as a string type (T=string). It’s okay to specify the parameter to be more
explicit about what’s going on and to have extra safety in case the “myString” parameter to
the function is actually not a string at all.
Generics allow for much more flexibility in TypeScript code, while still maintaining type safety.
They’re a powerful tool for creating reusable components and can be used with functions, classes,
interfaces, and more.
Let’s delve into more advanced examples of generics. As we’ve seen before, a generic function uses
a type variable as a placeholder for its arguments and return type. Here’s an example of a generic
function that takes two arguments of the same type:
6. Advanced Parts and Bad Parts of TypeScript 215
In TypeScript, a tuple is a type that allows you to express an array with a fixed number
of elements whose types are known, but need not be the same. Tuples are useful when you
need to store a collection of values of varied types in a single variable and maintain the order
and the specific type of each element. We can specify each element’s type in a tuple. Here
is an example of a tuple that holds a string and a number:
The elements of a tuple can be accessed using their index, similar to an array:
console.log(person[0]); will output Arun. Tuples are usually useful when we want a
function to return multiple values of different types and we don’t want to use an object for
that. Thus, Tuples in TypeScript are a useful convenient feature for cases where we need to
group together a few closely related values while maintaining their different types and the
sequence they are in.
It’s worth calling out that in the statement let pairOfNumbers = pair(1,2); we relied on the TypeScript
type inference. As mentioned prior, it’s okay depending on the need to be explicit or not.
We can also create a generic class. In this example, we create a simple,
generic Stack class that has a private property elements and methods push and pop:
1 class Stack<T> {
2 private elements: T[] = [];
3 push(element: T) {
4 this.elements.push(element);
5 }
6
7 pop(): T | undefined {
8 return this.elements.pop();
9 }
10 }
When we use this class, we have a type safety that will make sure to error when push is passed a
wrong argument:
6. Advanced Parts and Bad Parts of TypeScript 216
While you can use generics in both functions and methods, they behave differently and cannot be
interchanged directly. Consider the following example in which MyClass is a generic class and
myFunction is a generic function:
1 class MyClass<T> {
2 data: T;
3 constructor(data: T) {
4 this.data = data;
5 }
6
7 getData(): T {
8 // method
9 return this.data;
10 }
11 }
12
13 function myFunction<T>(data: T): T {
14 // function
15 return data;
16 }
Even though they both use the generic type parameter T, the scope and use of T differ significantly.
In MyClass, T is scoped to the class and is available throughout it. As a guideline, if we can, we
try to bring generic declaration closer to its usage so if you only need T in a method of a class, just
bring it to the method:
6. Advanced Parts and Bad Parts of TypeScript 217
1 class MyClass {
2 getData<T>(): T {
3 // method
4 return this.data;
5 }
6 }
On the other hand, in myFunction, T is only scoped to the function. Attempting to use T outside its
scope leads to an error as T is not in scope:
To avoid this confusion, always ensure that you define your type parameters in the correct scope.
And finally, we can also define a generic interface. For example, this KeyValuePair interface has key
and value properties set to generics types K and V:
When we use this interface, we can have various combinations of K and V and TypeScript will
enforce our types (note that we can also omit explicit generic type parameters):
And as long as we are on the topic of interfaces, here’s a good joke. How many TypeScript
programmers does it take to change a light bulb? — None. That’s a hardware issue, but they can
create an interface for it!
Sometimes you might want to limit the types that can be used with your generic class. This can be
done with generic constraints, but forgetting to use them when needed can lead to issues.
6. Advanced Parts and Bad Parts of TypeScript 218
1 class MyClass<T> {
2 private data: T;
3
4 constructor(data: T) {
5 this.data = data;
6 }
7
8 printDataLength() {
9 console.log(this.data.length); // Error: Property 'length' does not exist on typ\
10 e 'T'.
11 }
12 }
Here, we’re trying to use the length property of data, but TypeScript doesn’t know if T has a length
property. We should use a constraint to ensure T has length:
Guideline: If a type parameter is used in just a single location within the function signature, you
should seriously reevaluate its necessity. Often, such solitary type parameters might be redundant
and can complicate rather than simplify your function’s design. By limiting the use of unnecessary
type parameters, you ensure that your code remains clean, more maintainable, and easier to
understand. This practice also enhances type inference, allowing TypeScript to more effectively
deduce types, thereby improving the developer experience. When defining generic functions, aim
for clarity and efficiency by using type parameters that genuinely enhance the functionality and
interactivity of different data types within your function.
Let’s delve into the concept of a mirror function to illustrate the use of generics:
Is this a proper application of generics? In this instance, the generic argument, T, is used twice
following its introduction:
This fulfills the criteria for effective use of generics. It appropriately connects two types, indicating
that the type of the input parameter matches the return type.
Consider the following function:
Here, the generic parameter Z is used appropriately, appearing twice. However, X and Y are used
only once (beyond their declarations), suggesting an inefficiency in their usage. The function can
be simplified to use just one generic parameter:
Is this a beneficial or detrimental use of generics? The type parameter T appears only once, raising
concerns. Such “return-only generics”
are risky because they effectively equate to any type, albeit without explicitly using the keyword
any:
1 interface Mass {
2 kilograms: number;
3 grams: number;
4 }
5
6 const mass: Mass = parseJSON("");
In this code, Mass could represent any type, and it would still type check. If this flexibility is your
intention, you might as well specify the type as any:
This requires users of the function to assert the type of the result explicitly:
This practice is beneficial as it compels you to be explicit about your type assumptions, removing
any false sense of type safety.
Let’s explore another example of generic usage in TypeScript with the following function:
6. Advanced Parts and Bad Parts of TypeScript 221
In this case, the generic U is only used once, which may not be the most effective use of generics
since T appears both as a parameter type and as a constraint on U. A more streamlined approach
would be to incorporate the keyof T directly into the parameter type and eliminate U:
This version simplifies the function by removing an unnecessary generic type, making the function
easier to understand and maintain.
Now, consider a slightly different function:
This function is a solid example of good generic usage. The magic here is evident when considering
the function’s return type. When you check the function signature in an IDE, you’ll see its complete
type is:
The return type is T[U], meaning that U is utilized effectively twice, not just for fetching the property
but also for defining the return type. This ensures that the function maintains a strict relationship
between the property key and its value, preserving type safety throughout the operation.
It’s important to understand the effective usage of generics. These examples illustrate important
principles for using TypeScript generics:
1. Reduce Redundancy: If a generic type does not serve a distinct purpose or improve type
relationships within a function,
reconsider its usage. Simplifying the function signature not only improves readability but also
enhances code maintainability.
2. Maintain Type Relationships: Effective generics ensure that there is a meaningful relationship
between types. For instance, linking a property key to its value in an object helps maintain type
safety,
making the code more predictable and robust.
6. Advanced Parts and Bad Parts of TypeScript 222
3. Facilitate Type Inference: Properly used generics can aid TypeScript’s type inference capa-
bilities, making the developer’s experience smoother and reducing the likelihood of runtime
errors.
By adhering to these principles, developers can leverage TypeScript’s powerful type system to write
clearer, more effective code.
Writing generic functions is enjoyable, and it’s easy to overuse type parameters. Excessive type
parameters or unnecessary constraints can hinder type inference, leading to frustration for those
using your function.
1 interface Todo {
2 title: string;
3 description: string;
4 }
5
6 function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
7 return { ...todo, ...fieldsToUpdate };
8 }
And Pick<Type, Keys>: Allows you to create a new type by picking a set of properties Keys from
Type.
In this example, Pick<Todo, ‘title’> creates a new type TodoPreview that only includes the title
property from Todo. This method is very convenient in Object-Relational Mapping libraries and
API responses where we need to return only some properties and not the entire object.
Consider the following code in which we declare an interface type Person, function to log info and
then use them:
6. Advanced Parts and Bad Parts of TypeScript 223
1 interface Person {
2 name: string;
3 age: number;
4 address: string;
5 }
6
7 function logPersonNameAndAge(person: Person) {
8 console.log(person.name, person.age);
9 }
10
11 const person: Person = { name: "Alice", age: 25, address: "Wonderland" };
12 logPersonNameAndAge(person);
In this example, logPersonNameAndAge doesn’t care about the address field in Person, it only needs
name and age, but we’re forced to provide all three properties (name, age and address) when calling
the function. We can use the Pick utility type to make this function just use the necessary data:
In this revised example, logPersonNameAndAge now accepts an object that just needs to have
name and age. Thus, we don’t have to provide unnecessary fields when calling this function. This
approach with Pick is more correct (principle of least privilege) and also more convenient when you
don’t have that address.
Now, moving to Partial. Here a mistake of not using it can lead to reimplementing the types
(functionality) that could be achieved with utility types. This can lead to more verbose and harder
to maintain code. Ouch!
For example, consider this type where all fields of Person are optional:
1 type OptionalPerson = {
2 name?: string;
3 age?: number;
4 address?: string;
5 };
6
7 // ...rest of the code...
6. Advanced Parts and Bad Parts of TypeScript 224
In this code, OptionalPerson is essentially a Person where all fields are optional. This is a common
requirement, but implementing it manually is redundant because TypeScript provides the Partial
utility type:
In this revised code, OptionalPerson is defined using Partial, which achieves the same result but is
shorter and easier to understand. But most importantly, it will stay and get all the new changes
from the Person type in the future (stay in “sync”) as it changes.
In TypeScript, Generic Utility Types Pick and Partial are a set of predefined types that can be used
to perform transformations on other types. These utility types can help to simplify and make your
type definitions more expressive. Neglecting to use them when appropriate can lead to unnecessary
code verbosity, duplication, and potential type errors. By taking advantage of generic utility types
such as Pick and Partial, we can write TypeScript code that is more concise, easier to understand,
and more flexible.
Let’s create a more practical example where a function returns different types based on its input
parameter’s type. This function, wrapValue, will return an object containing either a string
representation of the value if it’s a number or the value itself if it’s any other type.
6. Advanced Parts and Bad Parts of TypeScript 225
1 type WrapType<T> = T extends number ? { type: "number"; value: string } : { type: "o\
2 ther"; value: T };
3
4 function wrapValue<T>(value: T): WrapType<T> {
5 if (typeof value === "number") {
6 return { type: "number", value: value.toString() } as WrapType<T>;
7 } else {
8 return { type: "other", value: value } as WrapType<T>;
9 }
10 }
11
12 // Example usages
13 const wrappedNumber = wrapValue(10); // Type is { type: 'number'; value: string; }
14 const wrappedString = wrapValue("hello"); // Type is { type: 'other'; value: string;\
15 }
16
17 console.log(wrappedNumber);
18 console.log(wrappedString);
In this example:
• WrapType<T> uses a conditional type to determine the structure of the return type based on
whether T is a number.
• The wrapValue function checks the runtime type of value and constructs the return object
accordingly.
• TypeScript correctly infers the type of the return value based on the input type, demonstrating
how conditional types can dynamically influence the flow of types through your program.
Conditional generic types are a robust feature in TypeScript that allows developers to write more
type-safe and flexible code by dynamically adjusting types based on the conditions applied to them.
In TypeScript, the extends keyword is indeed central to defining conditional types, but it’s important
to clarify that extends in this context doesn’t function exactly like inheritance in traditional object-
oriented programming. Instead, extends within a conditional type acts as a type constraint that
checks if a type can be assigned to another, serving as a conditional check rather than denoting
extension or inheritance.
Next, let’s dive into extends conditional types. The extends keyword is used in TypeScript’s
conditional types to evaluate a condition that resembles an “if-else” structure. Here’s the syntax:
In this syntax:
6. Advanced Parts and Bad Parts of TypeScript 226
Are there other conditional operators? While TypeScript doesn’t have other “conditional operators”
in the way that extends is used in conditional types, the language does support other operators and
techniques for creating sophisticated type behaviors:
• Mapped types: These allow you to create new types by transforming properties of an existing
type in a way that can depend on the properties’ keys and values.
• Utility types: TypeScript provides several built-in utility types (like Partial<T>, Readonly<T>,
and Pick<T, K>) that modify types in various ways, which can sometimes achieve ‘conditional’
effects based on the input types.
• Intersection and Union Types: These can be used to combine multiple types either by
intersecting them (TypeA & TypeB) or by allowing any one of several types (TypeA | TypeB).
Finally, let’s take a look at an example of using combined type operations. Here’s an example that
shows how you might combine various type operations to create complex behaviors without using
additional conditional operators:
1 type Admin = {
2 name: string;
3 privileges: string[];
4 };
5
6 type User = {
7 name: string;
8 email: string;
9 };
10
11 // Conditional type that checks the presence of 'privileges' property
12 type Staff<T> = T extends { privileges: any[] } ? Admin : User;
13
14 // Using a mapped type within a conditional expression
15 type Optional<T> = {
16 [P in keyof T]?: T[P];
17 };
18
19 type OptionalAdmin = Optional<Staff<Admin>>; // Partial properties;
20
21 type OptionalUser = Optional<Staff<User>>; // Partial properties;
6. Advanced Parts and Bad Parts of TypeScript 227
22
23 // Union type that allows function to accept either type
24 function setupProfile(user: Admin | User) {
25 // Function implementation
26 }
In summary, while extends is the primary tool for creating conditional types in TypeScript, the
language’s type system offers a rich set of tools for manipulating types that, when combined, can
mimic various conditional behaviors and create highly dynamic type conditions.
1 enum Color {
2 Red,
3 Green,
4 Blue,
5 }
6 function paint(color: Color) {
7 // ...
8 }
9 paint(Color.Red);
It’s simpler and doesn’t add unnecessary symbols to the runtime JavaScript. Moreover, some
TypeScript gurus like Dan, the author of Effective TypeScript, would even say enums are a bad
part of TypeScript (analogous to the famous book JavaScript The Good Parts). Why is that?
It can give you a false sense of type safety. Consider this example with the same Color enum:
6. Advanced Parts and Bad Parts of TypeScript 228
1 enum Color {
2 Red,
3 Green,
4 Blue,
5 }
6
7 let c: Color;
8
9 c = 1; // ok
10 console.log(Color[c]); // Green
11 c = 2 + 10; // ok
12 console.log(Color[c]); // undefined
Generally, I recommend using unions unless you have to use enums. This is because there are several
reasons why one might opt for a union type instead of an enum in specific circumstances:
• Simplicity and Readability: Union types often offer simpler, more JavaScript-like syntax than
enums. They allow you to directly use the values you care about (‘Car’, ‘Truck’, etc.), instead of
referencing them through an enum (VehicleType.Car, VehicleType.Truck, etc.). This can make
the code more readable, especially after TypeScript is transpiled to JavaScript.
• String-based Values: If your set of constants is string-based, using a union type can make your
code align more closely with the actual values used. When an enum is transpiled to JavaScript,
the values become numbers, which can be less meaningful. On the other hand, a string-based
union type retains its values, making the JavaScript output more understandable.
• Flexibility: Union types can be more flexible than enums, as they can represent any kind of
type, not just numeric or string values. For example, a union type could be used to represent a
value that can be either a number or a specific string.
• Smaller JavaScript Output and Performance Hit: Enums are a TypeScript feature that does not
exist in JavaScript, and TypeScript simulates them by generating additional code. This can
lead to larger JavaScript output, especially if you have many enums. Union types, on the other
hand, leverage existing JavaScript constructs and don’t add any extra code to your JavaScript
output.
• Use with External Libraries or APIs: When working with JavaScript libraries or external APIs,
you are likely dealing with raw values (often strings). In this case, using union types can be
more convenient and less error prone as you don’t need to map raw values to the corresponding
enum members.
It’s worth noting that enums can still be a great choice when you have a large set of related constants,
especially if you need to iterate over them, or when the auto-incrementing behavior of numeric
enums is useful.
6. Advanced Parts and Bad Parts of TypeScript 229
If you are not familiar with enums, short for enumerations, are a feature in TypeScript that
allows us to define a set of named constants. They are a way of giving more friendly and
consistent (from the word const) names to sets of numeric or string values. Thus, enums, or
enumerations, are a special type that consists of a set of named constants. It’s like declaring
“Monday” as 1, “Tuesday” as 2… but let’s be honest, we’d all rather declare “Monday” as
“still-weekend”, wouldn’t we?
In TypeScript, an enum is a special type used to define a collection of related values. This can be
numeric or string values.
Let’s see a basic numeric enum:
1 enum Direction {
2 Up,
3 Down,
4 Left,
5 Right,
6 }
1 enum Direction {
2 Up = 1,
3 Down,
4 Left,
5 Right,
6 }
In this case, Direction.Up would be 1, Direction.Down would be 2, and so on. TypeScript will
automatically increment the following members by 1.
TypeScript also supports string enums, where we give a string value to the members:
1 enum Direction {
2 Up = "UP",
3 Down = "DOWN",
4 Left = "LEFT",
5 Right = "RIGHT",
6 }
In this case, Direction.Up would be “UP”, Direction.Down would be “DOWN”, and so on.
Now, how does TypeScript translate enums to JavaScript? Let’s take our first numeric enum example
and see what TypeScript generates:
6. Advanced Parts and Bad Parts of TypeScript 230
1 var Direction;
2
3 (function (Direction) {
4 Direction[(Direction["Up"] = 0)] = "Up";
5 Direction[(Direction["Down"] = 1)] = "Down";
6 Direction[(Direction["Left"] = 2)] = "Left";
7 Direction[(Direction["Right"] = 3)] = "Right";
8 })(Direction || (Direction = {}));
As you can see, TypeScript generates a self-invoked function which populates an object with our
enum members. It makes the enum members available in both directions: you can get the string
name from the numeric value, and you can get the numeric value from the string name.
JavaScript does not have built-in support for enums, but they can be emulated using an object, as
TypeScript does when it compiles enum types to JavaScript.
The TypeScript enums provide type safety (sometimes) and autocompletion, which can make our
code less error-prone and easier to understand. But remember, because enums become objects in
JavaScript, they add an extra layer of abstraction and extra code to your final JavaScript bundle. If
performance is a critical aspect of your project, you might want to use simple constants or other
type-safe alternatives like union types.
This code snippet works, and we have a tuple named userInfo that represents user information. But
it’s not clear what userInfo[0] and userInfo[1] mean. They can be ID, name, superhero status and
so on. The code lacks context and readability. A better solution is to use an object:
1 let userInfo = {
2 id: 12,
3 name: "Alejandro Gabriel Torres Garcia",
4 };
5 console.log(userInfo.id); // Output: 12
6. Advanced Parts and Bad Parts of TypeScript 231
This code is easier to read and understand. By using an object, we provide meaningful property
names (id and name) that make it clear what each value represents. This improves the readability
and maintainability of the code, especially as the complexity of the data structure grows.
Using objects instead of tuples allows us to have better code readability (for human readers). We can
define interfaces or types to enforce the structure of the object, provide autocomplete suggestions,
and perform type-checking:
1 interface UserInfo {
2 id: number;
3 name: string;
4 }
5
6 let userInfo: UserInfo = {
7 id: 1,
8 name: "Alejandro Gabriel Torres Garcia",
9 };
10
11 console.log(userInfo.id); // Output: 1
With an interface or type definition, we have a clearer contract for the shape of the object, making
it easier for other developers to understand and use the code.
Tuples support labels for better readability. In TypeScript, tuple labels (also referred to as
“labeled tuple elements”) are a feature that enhances the readability and documentation of
tuple types by allowing you to assign names to the elements of a tuple. These labels do not
change the behavior of the tuple; they simply provide a way to document what each position
in the tuple is intended to represent, making the code easier to understand. Here’s how you
can define and use labeled tuples:
In the example above, the tuple Customer is defined with labels for each element. The labels
are id, name, and age corresponding to a number, string, and another number respectively.
Consider a function that returns a tuple representing a response from an API. Using labels can clarify
the meaning of each element of the tuple:
6. Advanced Parts and Bad Parts of TypeScript 232
In this function, fetchUserData returns a tuple with two labeled elements. The use of labels status
and body makes it clear what each part of the tuple contains, enhancing the readability of the
destructuring line and the subsequent usage. While tuple labels do not impact the functionality
of the tuple, they are a powerful tool for making code more understandable and maintainable. As
such, they are especially useful in complex systems where clarity of data structure and intent is
paramount.
While personally I recommend just using objects and consider tuples as a bad part of TypeScript,
you’ll still encounter tuples in other people’s code, if you work long enough with TypeScript. Let’s
try to see the differences in TypeScript between tuples and objects and see if they are truly two
different data structures that serve distinct purposes (or one needs to go, looking at you, tuples!).
Here’s a comparison between tuples and objects (understanding their differences and use cases is
important for effective TypeScript development):
Tuples:
• Tuples are ordered collections of elements with different types. Each element in a tuple can
have its own type, and the order of elements is fixed.
• Tuple types are defined using square brackets [] and separate the types of elements with
commas.
• Tuples are okay when you want to represent a collection of values where the order and the
types of elements matter. For example, representing coordinates (x, y) or a getStats function
that returns average and count.
• Tuple elements can be accessed using numeric indices, starting from 0.
• Tuples have a fixed length, and the type system enforces the expected number of elements in
a tuple.
• Tuples are often used when working with functions that need to accept or return multiple
values of different types.
• Tuples don’t provide description of the values in the JavaScript code even with tuple labels.
When we define a tuple type with labels, we specify a name for each element along with its
type. They help with code readability but only in the TypeScript code (not in the generated
JavaScript code).
Objects:
6. Advanced Parts and Bad Parts of TypeScript 233
• Objects are unordered collections of key-value pairs. Each value in an object can have its own
type, and the order of properties is not guaranteed.
• Object types are defined using curly braces {} and specify the types of properties along with
their names.
• Objects are suitable when you want to represent structured data where the association between
keys and values is important. For example, representing a person’s information with properties
like name, age, and address.
• Object properties can be accessed using their names (keys) using the dot notation (ob-
ject.property) or square bracket notation (object[‘property’]).
• Objects can have dynamic properties, meaning that you can add or remove properties during
runtime.
• Objects are often used when working with APIs, JSON data, or when modeling complex entities
with various properties.
In summary, tuples are used when the order of elements and their types matter, while objects are
used for unordered collections of key-value pairs. Choosing between tuples and objects depends on
the specific requirements and structure of the data you need to represent.
While tuples have their use cases, it’s important to consider whether they are the most appropriate
choice. When working with structured data, using objects with well-defined properties can greatly
enhance code clarity, maintainability, and leverage TypeScript’s type system effectively.
With type guards, we can avoid a lot of run-time errors. Ah, the joy of programming in a world
where type-checking errors have been banished to the farthest reaches of the void! Hold on tight,
and remember—in the world of TypeScript, things aren’t always what they typeof! We are going to
cover several techniques:
With all them, we typically would check the type using one of the following methods (or a
combination of them):
• typeof
• instanceof
• equality especially for with literals and null and undefined checks
• the in operator to check for presence (existence) of properties and methods on objects
• discriminated unions: checks on a common property
Let’s take a look at these main techniques for type narrowing in TypeScript one by one.
Ah, the typeof operator. At times, it’s your best friend in TypeScript, serving as a helpful guide
when traversing the labyrinth of data types. But other times, it’s a sneaky trickster that can lead
you into traps, leaving you knee-deep in type errors and confusion. So, let’s unravel the mystery
behind the misuse of the typeof operator.
Let me provide a little context on typeof in TypeScript (and JavaScript). We use the typeof operator
to get the type of a variable, property or value. This operator returns a string representing the type.
For instance,
It looks simple and handy, doesn’t it? It certainly is… for primitive types. However, the typeof
operator is not always so straightforward.
When we try to use typeof with the following types, it gets tricky:
Consider the next snippet that defines the interface Student with name and age, then creates an
instance of this student type only to have typeof return a generic object:
6. Advanced Parts and Bad Parts of TypeScript 236
1 interface Student {
2 name: string;
3 age: number;
4 }
5
6 let alena: Student = { name: "Alena", age: 21 };
7 console.log(typeof alena); // Outputs: "object", not "Student"
Here, even though alena is of type Student, typeof operator returns “object”. It can’t distinguish
between custom types or tell you when you’re dealing with a specific interface. This can lead to
type errors if you’re expecting “Student”. To mitigate this problem, we have to use one of the type
guard techniques covered in the previous section of this chapter, like instanceof, “in” or discriminated
unions.
Another pitfall of the typeof operator is how it deals with null values.
While null is considered a primitive type, typeof null confusingly returns “object”. This can create
confusion and lead to potential errors if not handled correctly. To avoid this pitfall, simply use
strict-check (triple equals) comparison: something === null.
Next is dealing with arrays and functions. While technically correct, typeof returns “object” for
arrays and “function” for functions, which might not be the granularity you’re looking for.
By the way, the same typeof output function happens with class which is probably not what we
expected:
6. Advanced Parts and Bad Parts of TypeScript 237
1 class Person {
2 name: string;
3 age: number;
4
5 constructor(name: string, age: number) {
6 this.name = name;
7 this.age = age;
8 }
9
10 sayHello() {
11 console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
12 }
13 }
14
15 console.log(typeof Person); // Outputs: "function"
If you are new to JavaScript you may be surprised to see that array is an object (which is a hashmap
really) while class is a function. What the heck you may say?! JavaScript are you drunk? Well,
maybe but that’s another story how the first version was developed in 11 days in 1995 and almost
all the bad parts still stuck to this day. Anyway, back to the types.
We get the object type for an array, because under the hood, type array is a special type of object
(associative) in JavaScript. And as far as functions are concerned, JavaScript classes are not really
classes but syntactic sugar for functions that create prototypal inheritance. Also, JavaScript treats
functions as first-class objects, granting them the ability to be passed as arguments to other functions,
returned from functions, and stored in variables and properties. Like regular objects, functions can
possess properties and methods, but their distinctive feature lies in their invocable nature, allowing
them to be called during the execution.
So we have what we have and to mitigate the shortfall of typeof with arrays and functions, we can
use one of the following approaches all of which output true:
To check if a variable is a class (function), we can use this hack that uses a regular expansion
to find keyword class in the text representation of the function under consideration:
Yes, it’s not pretty and hacky but it can demonstrate the mighty powers and flexibility of
JavaScript and TypeScript for that matter and luckily this need doesn’t come up that often
in JavaScript nor TypeScript.
In summary, while the typeof operator is a powerful tool when used correctly, it has its limitations
and quirks. Misusing it can lead to subtle bugs, type errors, and confusion. Understanding these
pitfalls will help you use the typeof operator more effectively and write cleaner, less error-prone
TypeScript code.
instanceof guard: Useful for narrowing types when dealing with class instances. For example, we
have two classes Bird and Fish and need to use the same function move on both:
1 class Bird {
2 fly() {
3 console.log("Flap Flap");
4 }
5 }
6
7 class Fish {
8 swim() {
9 console.log("Slosh");
10 }
11 }
12
6. Advanced Parts and Bad Parts of TypeScript 239
Checking for equality (like ===, !==, ==, !=) can also be used to narrow types, especially with literals
and null/undefined checks.
The in keyword checks for the existence of properties on objects and can be used as a type guard.
The usage will be like this with creating specific objects for pets of types Fish and Bird, and then
using the same function move on both of them:
6. Advanced Parts and Bad Parts of TypeScript 240
Discriminated unions involve a common property (discriminant) shared across all members of the
union. This property has literal types which TypeScript can use to narrow down the correct type.
1 type Event = { kind: "click"; x: number; y: number } | { kind: "keypress"; key: stri\
2 ng };
3
4 function handleEvent(event: Event) {
5 if (event.kind === "click") {
6 console.log(`Click at (${event.x}, ${event.y})`);
7 } else {
8 console.log(`Key pressed: ${event.key}`);
9 }
10 }
The name discriminated unions has nothing to do with the unjust or prejudicial treatment
of different categories of people. Instead it comes from the word “discriminant”. In
mathematics, the discriminant of a polynomial is a quantity that depends on the coefficients
and allows deducing some properties of the roots without computing them.
So all these techniques can be used right in the function to narrow down the types, or they can be
abstracted away into a separate functions for better code reuse (when you need this type guard more
than one time). These special functions act as custom type guard functions or predicates because
they use type predicate “is”. Ergo, we can define custom type guards by writing functions that use
a type predicate (arg is Type) as a return type:
6. Advanced Parts and Bad Parts of TypeScript 241
The example that we just saw works very similarly to our previous fish and bird example but of
course with a big advantage that now we have a separate isFish function to invoke many times from
different places. I usually put such functions in a shared library accessible from different parts of
the projects or even different projects.
Somewhat related to type narrowing are two assertions: non-null assertion operator ! and the type
assertion operator “as”. The non-null assertion operator tells TypeScript that an expression is not
null or undefined.
Type assertions can be used to tell TypeScript you know more about the type than TypeScript itself
does. It’s basically the last resort method and one step from giving up and putting //ts-ignore or
//ts-expect-error.
6. Advanced Parts and Bad Parts of TypeScript 242
These techniques, among others, enable TypeScript developers to manage and manipulate types
dynamically and safely, adhering closely to the application’s requirements for type correctness and
safety.
1 class Cat {
2 meow() {
3 console.log("Meow!");
4 }
5 }
6
7 let kitty = new Cat();
8 console.log(kitty instanceof Cat); // Outputs: true
The instanceof operator returns true if the left-hand operand (kitty in this case) is an instance of the
right-hand operand (Cat in this case). Since we created the kitty object using the new Cat() syntax,
it is indeed an instance of the Cat class. Therefore, the output of the console.log() statement will be
true. All in all, kitty is an instance of Cat, and all is right in the world.
6. Advanced Parts and Bad Parts of TypeScript 243
Now, let’s dive into some examples of when instanceof may lead you astray with the misuse cases
and their consequences. First, we’ll cover Primitive types. The instanceof operator is used to
determine whether an object is an instance of a certain class. If you try to use it with primitive
types like numbers, strings, or booleans, it will always return false. And we also can get the error:
“The left-hand side of an ‘instanceof’ expression must be of type ‘any’, an object type or a type
parameter”:
Even though the literals appear to match the type, instanceof returns false because these primitives
are not instances of their respective wrapper classes (Number, String, Boolean). Interestingly, if we
change primitives to wrapper objects instances, we’ll get true in all three statements:
Next, let’s talk about interfaces and custom types. The instanceof operator can only check whether
an object is an instance of a class. It can’t check if an object adheres to an interface or a custom type.
This is because interfaces and types exist only at compile time for static type checking and don’t
have a presence at runtime. Let’s say we have an interface Dog with a method bark. That’s what
dogs do, right? Then we try to determine the instance of the pet argument inside of the adoptPet
function only to get an error “ ‘Dog’ only refers to a type, but is being used as a value here”:
6. Advanced Parts and Bad Parts of TypeScript 244
1 interface Dog {
2 bark(): void;
3 }
4
5 function adoptPet(pet: Dog) {
6 if (pet instanceof Dog) {
7 // Error: 'Dog' only refers to a type, but is being used as a value here.
8 console.log("Adopted a dog!");
9 }
10 }
The code above will give you a compile error because you can’t use an interface with the instanceof
operator. This is simply because interface does NOT exist at runtime. They are purely a TypeScript-
time construct that gets stripped at compile time. The proper way is to check if pet is of a specific
type. Thus, we need to create a class CaucasianShepherd that implements the interface Dog and
then use the class in adoptPet:
1 interface Dog {
2 bark(): void;
3 }
4
5 class CaucasianShepherd implements Dog {
6 bark() {
7 console.log("woof woof");
8 }
9 }
10
11 function adoptPet(pet: any) {
12 if (pet instanceof CaucasianShepherd) {
13 console.log("Adopted a dog!");
14 } else {
15 console.log("Adopted an alien");
16 }
17 }
18
19 adoptPet(new CaucasianShepherd());
20 adoptPet(new Array(1, 2, 3));
If you still want to check if an object pet is adhering to the interface Dog, then we can use duck
typing. For example, we can check for the bark method:
6. Advanced Parts and Bad Parts of TypeScript 245
1 interface Dog {
2 bark(): void;
3 }
4
5 function adoptPet(pet: any) {
6 if (typeof pet.bark === "function") {
7 console.log("Adopted a dog!");
8 pet.bark();
9 }
10 }
Finally, there’s a case of instances from different execution contexts. Another subtler trap with
instanceof comes into play when dealing with objects from different JavaScript execution contexts
(like iframes or worker threads). Each context has its own separate global environment, and hence its
own set of constructor functions. An object from one context is not an instance of the constructor
from another context even when they should be the same, e.g., Array and Array. This principle
also applies to other JavaScript execution contexts such as Web Workers, Service Workers, or
different Node.js modules. This is an important subtlety to remember when working with multiple
JavaScript/TypeScript execution contexts in order to avoid surprising instanceof results.
The lesson? Treat instanceof like a bouncer for your class-based party. It can identify the guests
(instances) who have the invitation (class constructor). However, it will be left confused when
presented with party-crashers (primitive types) or incognito guests (interfaces). Understanding
its strengths and weaknesses can help you avoid those sneaky, hard-to-spot bugs and keep the
TypeScript party going!
The mistake of “failing to use discriminated unions” comes when you’re dealing with such types,
and you don’t take advantage of this powerful TypeScript feature. It can lead to more complex,
harder to read and understand code, and it can also make it easier to introduce bugs, as you may
forget to handle all possible types correctly.
Here’s a simple example to illustrate. Let’s say you have a function that takes an object that can
either be a circle or a rectangle, and you want to calculate its area:
1 type Circle = {
2 radius: number;
3 };
4
5 type Rectangle = {
6 width: number;
7 height: number;
8 };
9
10 type Shape = Circle | Rectangle;
11
12 function getArea(shape: Shape) {
13 if ("radius" in shape) {
14 // circle
15 return Math.PI * shape.radius ** 2;
16 } else {
17 // rectangle
18 return shape.width * shape.height;
19 }
20 }
This works, but it relies on checking for the presence of a radius property to distinguish between
circles and rectangles (i.e., duck typing). If you later add another shape type with a radius property
like cylinder, this function will give erroneous results, because the other shape has radius but needs
a different formula:
1 type Cylinder = {
2 radius: number;
3 height: number;
4 };
5
6 // ...
7
8 type Shape = Circle | Rectangle | Cylinder;
9
6. Advanced Parts and Bad Parts of TypeScript 247
Instead of relying on radius property, we should just implement a discriminated union by adding
property “kind” to distinguish the types of geometrical shapes:
1 type Circle = {
2 kind: "circle";
3 radius: number;
4 };
5
6 type Rectangle = {
7 kind: "rectangle";
8 width: number;
9 height: number;
10 };
11
12 type Cylinder = {
13 kind: "cylinder";
14 radius: number;
15 height: number;
16 };
17
18 type Shape = Circle | Rectangle | Cylinder;
19
20 function getArea(shape: Shape) {
21 switch (shape.kind) {
22 case "circle":
23 return Math.PI * shape.radius ** 2;
24
25 case "rectangle":
26 return shape.width * shape.height;
27
6. Advanced Parts and Bad Parts of TypeScript 248
28 case "cylinder":
29 return Math.PI * shape.radius * (shape.radius + shape.height);
30 }
31 }
This version uses a kind property as the discriminant, which makes it clear what type each object
is, and makes it easy to handle each type correctly and exhaustively. The thing that I like the most
about this approach over duck typing is that it’s more predictable and more readable.
An added benefit is that if you later add another shape type and have noImplicitReturns enabled,
then TypeScript will help you ensure that the code is handling all values of kind. For example, if we
add Square but don’t add implementation, then we’ll get a convenient error: “ Not all code paths
return a value.“ until we add a square case.
1 type Square = {
2 kind: "square";
3 height: number;
4 };
5
6 type Shape = Circle | Rectangle | Cylinder | Square;
7
8 function getArea(shape: Shape) {
9 // Not all code paths return a value.
10
11 switch (shape.kind) {
12 case "circle":
13 return Math.PI * shape.radius ** 2;
14
15 case "rectangle":
16 return shape.width * shape.height;
17
18 case "cylinder":
19 return Math.PI * shape.radius * (shape.radius + shape.height);
20 }
21 }
Some people say that noImplicitReturns has false positives while others that it’s just a stylistic matter,
so use it with caution (if at all). Lastly, noImplicitReturns is not part of the strict mode so it has to
be enabled separately. Speaking of strict, TypeScript is so strict that even its jokes need to pass type
checking.
Even without noImplicitReturns, you’ll be able to catch the bug early because you’ll get undefined
when trying to get area of square with its implementation in the switch case:
6. Advanced Parts and Bad Parts of TypeScript 249
1 type Square = {
2 kind: "square";
3 height: number;
4 };
5
6 type Shape = Circle | Rectangle | Cylinder | Square;
7
8 function getArea(shape: Shape) {
9 switch (shape.kind) {
10 case "circle":
11 return Math.PI * shape.radius ** 2;
12
13 case "rectangle":
14 return shape.width * shape.height;
15
16 case "cylinder":
17 return Math.PI * shape.radius * (shape.radius + shape.height);
18
19 // case 'square':
20 // return shape.height ^ 2;
21 }
22 }
23
24 const mySquare: Shape = {
25 kind: "square",
26 height: 10,
27 };
28
29 console.log(getArea(mySquare));
dive in, shall we? Speaking of a dance, what are the TypeScript developer’s favorite songs? “Can’t
help falling in type” by Elvis Presley and “U Can’t Type This” by M.C. Hammer!
There are several mechanisms and patterns used for asynchronous programming, including call-
backs, async/await, Promises, and generators.
Let’s focus on promises and async/await because they are the most popular syntaxes, they are elegant
yet powerful and they are compatible (promise can be called with await inside of an async function
or with top level awaits).
Using async/await in TypeScript (and JavaScript) can greatly simplify the handling of asynchronous
code by making it appear more like synchronous code. However, this syntactic sugar can also mask
some complexities and pitfalls, leading to subtle bugs if not used correctly.
Here’s an in-depth look at the “Overlooking Async/Await Pitfalls” issue with code examples
demonstrating common mistakes and how to fix them.
This mistake is more about JavaScript because most of the async coding is coming to TypeScript
from there, but there are a few places where we need to pay attention to types.
In the above example, if the network request fails, or the JSON parsing fails, the error will not be
caught, potentially leading to an unhandled promise rejection.
The fix involves proper implementation of error handling with try/catch and in real life instead
of console log those would be logs sent to a tracing cloud service and user-friendly errors will be
showed on the UI:
6. Advanced Parts and Bad Parts of TypeScript 251
The erroneous result will be that we don’t see userId on the last line:
The fix is to fix add await to processUser but to do so we need an async wrapper (or use top level
await):
6. Advanced Parts and Bad Parts of TypeScript 252
I get about 410ms execution on my super blazingly fast Apple chip arm computer.
6. Advanced Parts and Bad Parts of TypeScript 254
Figure 4. A screenshot of a Chrome Dev Tools console showing 410ms execution time
To fix, we can use super cool and built-in (into JavaScript) method Promise.all:
22
23 main();
Of course, concurrent running is better so we get down to 101ms which is the time of the slowest
setTimeout (my simulation of an HTTP call to fetch a user).
Figure 5. A screenshot of a Chrome Dev Tools console showing execution time of 101ms.
also specify types explicitly for more robustness). As you’ll see fetchDataWithTimeout will have
the return type Promise, so you can see how type inference works in action:
To sum it up, Promises and async/await in TypeScript is a powerful feature that can make
asynchronous code simpler and more readable. However, it’s essential to be aware of common
pitfalls such as error handling, promise management, and efficient use of concurrency. Proper use
of try/catch blocks, correct promise chaining, and leveraging Promise.all for concurrent tasks can
help avoid these common issues and ensure that your async functions are robust and efficient.
6.11. Summary
• Generic constraints allow you to specify that type parameters must have certain properties
or methods. This gives you more control over the types that can be used with your generic
functions, methods, or classes. When properly implemented, generic classes can serve as
blueprints for creating multiple classes that work with different types, while still preserving
type safety.
• Type parameters are like variables for types (that can be passed to a function, class, or type),
allowing you to write flexible and reusable functions, interfaces, and classes that can work
with different types while maintaining type safety.
• Conditional types in TypeScript can be used to create complex type relationships and can be
particularly useful when working with generics (and types in general).
6. Advanced Parts and Bad Parts of TypeScript 257
• TypeScript provides several utility types, like Partial, that can make working with generics
easier and more efficient.
• TypeScript’s type inference mechanism works with generics to automatically determine type
parameters when they can be inferred from the context. It’s easy to over-complicate generic
types, but you should aim to follow the rule of two and keep your generics as simple and
readable as possible. Generics are a tool for improving code quality, not a way to show off
your TypeScript skills.
• Default generic types can be specified in case a type argument is not provided when using a
generic function or class, which allows for more flexible and user-friendly components.
• Opt out of bad parts of TypeScript such as enums and tuples.
• Know different ways to narrow types: typeof, instanceof, in, equality checks for null and
undefined, duck typing and discriminated union.
7. Outro
As we wrap up this tour in top TypeScript mistakes, remember: TypeScript is like a drama mixed
with comedy. It can be as strict as a grammar teacher or as forgiving as your best friend, depending
on how you set it up. It’s a powerful tool that, when used wisely, can significantly reduce errors
and improve the maintainability of your code.
So, take these lessons, sprinkle them with your own experiences, and write TypeScript that even
Shakespeare would be proud of—“To type, or not to type: that is the question!” And with TypeScript,
the answer is always to type, but type wisely.
Remember, the road to becoming a TypeScript maestro is paved with mistakes—each one a stepping
stone towards deeper understanding and greater skill. My hope is that this book has not only
helped you avoid some of these common f*ck ups, but also instilled in you a sense of resilience
and curiosity. Coding is as much about solving problems as it is about embracing the learning
process, and sometimes that means making mistakes and learning from them.
As you continue your coding journey, keep in mind that perfection is not the goal. Progress is.
Because of this, keep experimenting, keep pushing your boundaries, and most importantly, keep
coding. And who knows? Maybe one day, you’ll have your own collection of stories and lessons to
share with the next generation of developers.
Thank you for joining me on this merry adventure through the ups and downs of TypeScript. It’s
been a pleasure being your guide, and I look forward to seeing the incredible things you’ll create
with your newfound knowledge.
And remember, if all else fails, you can always blame JavaScript. :-)
Cheers,
Professor Azat MARDAN,
Distinguished Software Engineer
Microsoft Most Valuable Professional,
Author of React Quickly, Practical Node.js, Pro Express.js, and Full Stack JavaScript