3 Key Steps to Make Your TypeScript code safer

Fully utilising TypeScript's potential to make your code robust and error-free takes considerable effort, because it's too permissive out of the box.

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:

  1. Configuring the TS compiler correctly

  2. Improving the standard library types and/or types of external libraries

  3. 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 implied any type.

  • strictNullChecks: When type checking, take into account null and undefined.

  • strictFunctionTypes: When assigning functions, check to ensure parameters and the return values are subtype-compatible.

  • strictBindCallApply: Check that the arguments for bind, call, and apply methods match the original function.

  • strictPropertyInitialization: Check for class properties that are declared but not set in the constructor.

  • noImplicitThis: Enable error reporting when this is given the type any.

  • useUnknownInCatchVariables: Default catch clause variables as unknown instead of any.

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 anys 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 betweenunknownandany: 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 anys 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.