Typed Linting: The Most Powerful TypeScript Linting Ever
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 JavaScript 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.
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 file's AST, the lint rule would have no way of knowing whether work
is deprecated:
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:
/** @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 even 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 any
s
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 any
s.
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 reliable way to determine that the const { getItem }
access was unsafe.
Async Race Conditions
Even if your code is 100% typed, has no any
s, and doesn't misuse scopes, it's still possible to have bugs that can only easily be detected by typed linting.
Asynchronous code with Promise
s 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
:
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:
- Determine the appropriate TSConfig dictating how to generate that file's type information
- 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! 💖