3 Key Steps to Make Your TypeScript code safer
Introduction
TypeScript can help greatly improve the development and maintenance of large projects. However, there are some problems that it does not address out of the box. For example, the any
type can sneak into a project from various sources, such as TypeScript's standard library or poorly-typed libraries from npm. Also, the default configuration of TypeScript's compiler is too permissive and allows developers to write code that may result in runtime errors that could have been caught at compile time with a more restrictive compiler configuration.
In this blog post, I will outline the steps that should be taken to ensure that TypeScript's full power to improve the quality of a project's code is utilised:
Configuring the TS compiler correctly
Improving the standard library types and/or types of external libraries
Using the type-checked mode of
typescript-eslint
plugin to extend the type-checking capabilities of TypeScript
There's a lot to cover, and I will not, for example, discuss every rule I recommend enabling in tsconfig.json
in detail. The steps suggested here should provide you with a good starting point for new TypeScript projects or ideas for improving an existing project. Please take the time to look at the references throughout the blog post to ensure that you configure everything optimally to meet your project's and team's unique needs.
Step 1: Configuring the TS Compiler
The default configuration in tsconfig.json
can make you miss out on a lot of the benefits of TypeScript. Here's the configuration I recommend using as a starting point. A few options that are disabled by default are enabled in it. I will briefly explain the rationale behind enabling these additional options next.
{
"compilerOptions": {
"strict": true,
/* Enable all strict type-checking options. */
"noUncheckedIndexedAccess": true,
/* Add 'undefined' to a type when accessed using an index. */
"noUnusedLocals": true,
/* Report errors on unused local variables. */
"noUnusedParameters": true,
/* Report errors on unused parameters in functions. */
"noFallthroughCasesInSwitch": true,
/* Report errors for fallthrough cases in switch statements. */
"noImplicitReturns": true,
/* Check all code paths in a function to ensure they return a value. */
}
}
The strict
type-checking options include the following:
noImplicitAny
: Enable error reporting for expressions and declarations with an impliedany
type.strictNullChecks
: When type checking, take into accountnull
andundefined
.strictFunctionTypes
: When assigning functions, check to ensure parameters and the return values are subtype-compatible.strictBindCallApply
: Check that the arguments forbind
,call
, andapply
methods match the original function.strictPropertyInitialization
: Check for class properties that are declared but not set in the constructor.noImplicitThis
: Enable error reporting whenthis
is given the typeany
.useUnknownInCatchVariables
: Default catch clause variables asunknown
instead ofany
.
Note: All the definitions of the compiler options above are from https://www.typescriptlang.org/docs/handbook/compiler-options.html.
Visit the TSConfig reference to learn more about them (this page has more detailed explanations of every compiler option).
If you have enabled the strict
option (and it's hard to think of a project that would benefit from not enabling it), you should probably remove the options listed above to avoid duplication. strict
is enabled by default when you use Vite, Create React App, or Next.js command line tools to create your project, but the other options listed above aren't usually enabled by default in tsconfig.json
when a project is created using a tool like CRA.
Of all the options above, strict
is the most critical one to enable. For the sake of brevity, I will only explain noImplicitAny
and strictNullChecks
here.
noImplicitAny
Using the any
type obviously disables all type-checking rules. To ensure we don't miss out on TypeScript's benefits, the number of occurrences of any
in a codebase needs to be kept to an absolute minimum. Enabling this option will make the compiler throw errors where the type of a variable is inferred as any
.
// Case 1: noImplicitAny disabled
function capitaliseString(str) {
// TS automatically infers the value of str as any
return str.toUpperCase();
}
// TypeError: str.toUpperCase is not a function
const result = capitaliseString(8);
// ========================================================
// Case 2: noImplicitAny enabled
function capitaliseString(str: string) {
return str.toUpperCase();
}
// TSError: ⨯ Unable to compile TypeScript:
// Argument of type 'number' is not assignable to parameter
// of type 'string'.
const result = capitaliseString(8);
As you can see in this example, enabling this option helps avoid runtime errors.
strictNullChecks
The problem this option fixes is that null
and undefined
are valid values for any type by default in TypeScript. Enabling this rule helps catch more bugs in development.
const companies = [
{ name: 'Snowflake', marketCap: "100B" },
{ name: 'Oracle', marketCap: "50B" },
{ name: 'Microsoft', marketCap: "1T" },
];
const meta = companies.find(c => c.name === 'Meta');
// With strictNullChecks enabled, this will produce the following error:
// TSError: ⨯ Unable to compile TypeScript:
// 'meta' is possibly 'undefined'.
console.log(`Meta's market cap is ${meta.marketCap}`);
As you can see, without this option enabled, the code above would have resulted in a runtime error. This compiler option forces developers to explicitly handle the possibility of undefined
or null
.
A Couple More Recommendations
Take a closer look at the Type Checking options in the TSConfig reference to see if there are any other ones you might want to enable for your project.
Remove the Backwards Compatibility options listed here if they are enabled in your compiler configuration, as they can weaken the type system.
Step 2: Fixing the TS Standard Library Types
Once you've finalised your compiler configuration, it's time to fix the next potential source of problems for your project – the standard library types. When I talk about the TypeScript standard library types, I am referring to the set of d.ts
files (files that are used to provide TypeScript type information about JavaScript APIs) that can be found here. These include ECMAScript language features (JavaScript APIs like functions on Array), Web Platform APIs that are available in the global scope, internationalization APIs and more.
One of the issues is that TypeScript's standard library contains over 1,000 instances of the any
type. For example, methods like JSON.parse()
, json()
, as well as the Fetch API return any
. Since we would like to avoid introducing any
s into our application code, one thing that makes sense to do is to make them return unknown
. One way to tackle this problem is by introducing a library called ts-reset
into your project.
A note on the difference betweenunknown
andany
: unknown
is a special type that was introduced in TypeScript 3.0. Any variable can be assigned to the unknown
type, but you have to do a type check or a type assertion to operate on unknown
. You can assign anything to any
AND you can perform any operation on any
. That's why the use of any
is discouraged.
Using ts-reset
The official website of the project provides information on specifically what changes it makes to TypeScript's built-in typings. It makes some type declarations more permissive and others more restrictive. It is an opinionated list of rules, but you can easily use only specific rules instead of all of them, as explained in the docs. I think this is a great way to quickly improve the standard library types for your application. However, because ts-reset
redefines global types of the standard library, it should only be used in application code, not in library code. Otherwise, a user importing a library that uses ts-reset
would be unknowingly opting in to ts-reset
for their whole project.
Using TypeScript's Declaration Merging
Since ts-reset
only applies a very small number of changes to the global types, we need a way to make additional changes ourselves if necessary. TypeScript's declaration merging feature is what can be used to achieve this.
// Merging interfaces is an example of declaration merging.
interface Man {
height: number;
weight: number;
}
interface Man {
IQ: number;
}
let man: Man = { height: 180, weight: 80, IQ: 160 };
Declaration merging means that the compiler merges two or more separate declarations declared with the same name into a single definition, and the merged definition has the features of all of the original declarations.
This feature can be used to extend the types of the standard library or external libraries.
Let's use it to change Array.isArray()
to avoid introducing unwanted any
s into our code (this is already implemented as a rule in ts-reset
, I'm just using it as an example of how declaration merging can be used for our purposes). To figure out that isArray
method is a part of ArrayConstructor
interface, simply use an IDE's "go to type definition" function.
// before
if (Array.isArray(arg)) {
console.log(arg) // any[]
}
// add this to your project
declare global {
interface ArrayConstructor {
isArray(arg: any): arg is unknown[];
}
}
// after
if (Array.isArray(arg)) {
console.log(arg) // unknown[]
}
Step 3: Choosing ESLint Configurations and Adding More ESLint Rules
There are problems that we still have not addressed. For example, we need to use ESLint to stop developers from using any
explicitly among other things. There are a number of other improvements to code robustness that can only be made by using a type-aware linter.
Getting Started and Enabling Type-Aware Linting
If typescript-eslint
plugin or eslint
are not yet installed, first add them to your project:
npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint
Next, create a .eslintrc.cjs
config file in the root of your project:
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended-type-checked',
],
plugins: ['@typescript-eslint'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: true,
tsconfigRootDir: __dirname,
},
root: true,
};
This configuration enables the type-checked mode of typescript-eslint
. The type-checked mode of typescript-eslint
enhances ESLint's capabilities by providing the type information (variable types, function signatures, etc.) from the TypeScript compiler to ESLint rules.
To run ESLint, run this command at the root of your project:
npx eslint .
Run the linter in pre-commit hooks and in CI to spot rule violations before committing or deploying the code.
Choosing the Right typescript-eslint
Configurations
Please refer to this page in the docs for comprehensive information on the subject. A configuration is just a set of ESLint rules. At a minimum, you should be using recommended-type-checked
. This configuration includes rules such as no-explicit-any
and no-unsafe-member-access
(this rule disallows accessing members of an any
-typed value). Other configurations, such as strict-type-checked
, are more opinionated and include more rules that may not be needed for your project. The configurations that you should pick depend on your project's unique needs, so just explore the docs to find the right set of them.
Adding More Rules or Creating Your Own
There are, however, some rules that do not appear in any of the shareable configurations for typescript-eslint
that you may want to include in your project. For example, you can add a rule called no-implicit-coercion
(rule details) to your ESLint config file like this:
// .eslintrc.cjs
module.exports = {
...
rules: {
...
"no-implicit-coercion": ["error", {"boolean": true, "number": true, "string": true}]
}
}
Finally, you can create custom ESLint rules if the available rules do not cover your use case.
Conclusion
Fully utilising TypeScript's potential to make your project more robust and error-free takes considerable research and effort. Using typed ESLint rules also comes with a performance penalty that can be quite noticeable for larger projects. However, in my opinion, the advantages of the additional safety that you can get by following the steps in this blog post clearly outweigh the disadvantages.
Subscribe to my newsletter
Read articles from Nik Dergunov directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Nik Dergunov
Nik Dergunov
I'm a full-stack web developer. I consider myself a T-shaped engineer – my main area of focus is front-end web development. I write about React, TypeScript, and other front-end web development topics.