Skip to main content

Typed Linting: The Most Powerful TypeScript Linting Ever

· 9 min read
Josh Goldberg
typescript-eslint Maintainer

Linting with type information, also called "typed linting" or "type-aware linting", is the act of writing lint rules that use type information to understand your code. Typed linting rules as provided by typescript-eslint are the most powerful JavaScript/TypeScript linting in common use today.

In this blog post, we'll give a high-level overview of how linting with type information works, why it's so much more powerful than traditional linting, and some of the useful rules you can enable that use it.

Recap: Type Information?

Traditional lint rules operate on one file at a time. They look at a description of code in each file and report complaints if that file seems to contain bad practices. That description is called an Abstract Syntax Tree, or AST.

tip

For a primer on ASTs and linting, see ASTs and typescript-eslint.

Each file's AST contains only information for that file, not any other files. Lint rules that rely only on the file's AST don't have a way to understand code imported from other files, such as in ESM import statements. Not being able to understand code from other files severely limits lint rules.

As an example, suppose you enable a lint rule like @typescript-eslint/no-deprecated to prevent calling to code with a @deprecated JSDoc. Using just the following index.ts file's AST, the lint rule would have no way of knowing whether work is deprecated:

index.ts
import { work } from './worker';

// Is this safe? Does calling work violate any rules? We don't know!
work();

Type information refers to the information a type checker such as TypeScript generates to understand your code. Type checkers read code, determine what types each value may be, and store that "type information". TypeScript and tools that call to TypeScript's APIs can then use that type information to understand the project's code.

In the earlier example, type information would be able to inform a lint rule running in index.ts that the work import resolves to a function in another file:

worker.ts
/** @deprecated - Don't do this! */
export function work() {
// ...
}

...which would allow the lint rule to report a complaint that the work() call is to a function marked as @deprecated.

typescript-eslint allows lint rules to retrieve type information using TypeScript's APIs. In doing so, they can make decisions on linted files using information outside each individual file.

Common Uses for Typed Linting

Cross-file type information is a powerful addition to lint rules. Knowing the types of pieces of your code allows lint rules to flag for risky behavior specific to certain types. The following sections show several of the most common uses for lint rules that rely on type information.

Unsafe anys

The @typescript-eslint/no-unsafe-* family of rules checks for risky uses of any typed values. This is useful because the any type can easily slip into code and reduce type safety, despite being allowed by the TypeScript type checker.

For example, the following code that logs a member of an object parsed from a string produces no type errors in type checking. JSON.parse() returns any, and arbitrary property accesses are allowed on values of type any.

However, @typescript-eslint/no-unsafe-member-access would report [key] might not be a property on the object:

function getDataKey(rawData: string, key: string): string {
return JSON.parse(rawData)[key];
// ~~~~~
// Unsafe member access [key] on an `any` value.
// eslint(@typescript-eslint/no-unsafe-member-access)
}

The lint rule is right to report. Calls to the getDataKey function can return a value that's not a string, despite the function's explicit return type annotation. That can lead to unexpected behavior at runtime:

console.log(getDataKey(`{ "blue": "cheese" }`, 'bleu').toUpperCase());
// Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')

Without type information to indicate the types of JSON.parse and key, there would have been no way to determine that the [key] member access was unsafe.

Method Call Scoping

Runtime crashes caused by misuses of typed code are possible even with no anys.

For example, class method functions don't preserve their class scope when passed as standalone variables ("unbound"). TypeScript still allows them to be called without the proper this scope.

The global localStorage object in browsers has several properties that must be called with a this bound to localStorage. The @typescript-eslint/unbound-method lint rule can report on unsafe references to those properties, such as accessing getItem:

const { getItem } = localStorage;
// ~~~~~~~
// Avoid referencing unbound methods which may cause unintentional scoping of `this`.
// eslint(@typescript-eslint/unbound-method)

That's useful because calls to getItem that aren't bound to localStorage cause an exception at runtime:

getItem('...');
// Uncaught TypeError: Illegal invocation

Without type information to indicate the types of localStorage and its getItem property, there would have been no way to determine that the const { getItem } access was unsafe.

Async Race Conditions

Even if your code is 100% typed, has no anys, and doesn't misuse scopes, it's still possible to have bugs that can only easily be detected by typed linting. Asynchronous code with Promises in particular can introduce subtle issues that are completely type-safe.

Suppose your code is meant to run an asynchronous readFromCache function before reading from the file system:

import { fs } from 'node:fs/promises';
import { readFromCache } from './caching';

const filePath = './data.json';

readFromCache(filePath);

await fs.rm(filePath);

Do you see the potential bug?

If readFromCache is asynchronous (returns a Promise), then calling it and not awaiting its returned Promise could lead to race conditions in code. Its asynchronous or delayed logic might not get to reading from the filePath before fs.rm(filePath) runs.

This is commonly referred to as a "floating" Promise: one that is created but not appropriately handled. The @typescript-eslint/no-floating-promises lint rule would report on that floating Promise:

readFromCache(filePath);
// Promises must be awaited, end with a call to .catch, end with a call to .then
// with a rejection handler or be explicitly marked as ignored with the `void` operator.
// eslint(@typescript-eslint/no-floating-promises

...and can give an editor suggestion to add a missing await:

- readFromCache(filePath);
+ await readFromCache(filePath);

Determining whether code is creating a floating Promise is only possible when the types of code are known. Otherwise, lint rules would have no way of knowing which imports from other files could potentially create a Promise that needs to be handled.

Custom Rules

Typed linting isn't restricted to just typescript-eslint rules. It can be used in community ESLint plugins, as well as custom rules specific to your project.

One common example used by teams is to codemod from a deprecated API to its replacement. Typed linting is often necessary to determine which pieces of code call to the old API.

As an example, consider the following fetch() POST call that sends data to an intake API. Suppose the intake endpoint is migrating from sending [string, string] tuples to sending key-value pairs. A typed lint rule could determine that the data is in the old format:

import { endpoints } from "~/api";

const rawData = ["key", "value"] as const;

await fetch(endpoints.intake, {
data: JSON.stringify(rawData)
// ~~~~~~~
// Don't pass a tuple to endpoints.intake. Pass a key-value object instead.
// eslint(@my-team/custom-rule)
// ...
method: "POST",
});

...and provide a code fix to automatically migrate to the new format:

import { endpoints } from "~/api";

const rawData = ["key", "value"] as const;

await fetch(endpoints.intake, {
- data: JSON.stringify(rawData)
+ data: JSON.stringify(Object.fromEntries(rawData))
// ...
method: "POST",
});

Knowing that the fetch() call was being sent to endpoints.intake and that the type of the data was a tuple takes typed linting.

That kind of migration codemod is one of the ways typed linting can be utilized for project- or team-specific rules. See Developers > Custom Rules for more documentation on building your own ESLint rules with typescript-eslint.

Enabling Typed Linting

You can add typed linting to your ESLint configuration by following the steps in Linting with Type Information. We recommend doing so by enabling parserOptions.projectService:

eslint.config.js
import tseslint from 'typescript-eslint';

export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
);

typescript-eslint will then use TypeScript APIs behind-the-scenes, to, for each file being linted:

  1. Determine the appropriate TSConfig dictating how to generate that file's type information
  2. Make APIs available to lint rules to retrieve type information for the file

Lint rules that opt into type information will then be able to use those APIs when linting your code.

Drawbacks of Typed Linting

Linting with type information comes with two drawbacks: configuration complexity and a performance penalty.

For configuring typed linting, parserOptions.projectService solves configuration difficulties for most projects. The more manual parserOptions.project is also available for more complex project setups. See Troubleshooting & FAQs > Typed Linting for details on common issues.

For performance, it is inevitable that typed linting will slow your linting down to roughly the speed of type checking your project. Typed lint rules call to the same TypeScript APIs as the command-line tsc. If linting your project is much slower than running tsc on the same set of files, see Troubleshooting & FAQs > Typed Linting > Performance.

Final Thoughts

In our experience, the additional bug catching and features added by typed linting are well worth the costs of configuration and performance. Typed linting allows lint rules to act with much greater confidence on a wider area of checking, including avoiding unsafe any uses, enforcing proper this scopes, and catching asynchronous code mishaps.

If you haven't yet tried out typed linting using the typescript-eslint rules mentioned in this blog post, we'd strongly recommend going through our Linting with Type Information guide.

Supporting typescript-eslint

If you enjoyed this blog post and/or use typescript-eslint, please consider supporting us on Open Collective. We're a small volunteer team and could use your support to make the ESLint experience on TypeScript great. Thanks! 💖