Topic 15.2
Linting with typescript-eslint
In one line
A linter reads your code for risky habits that the compiler allows. typescript-eslint can ask the type checker what each value really is, so it catches forgotten promises and leaked any values that tsc stays silent about.
Think of it like this
A spell-checker and a style editor read the same essay. The spell-checker (the compiler) says "this word doesn't exist". The style editor (the linter) says "this word exists, but you used it where readers will be confused". Both are useful, and neither replaces the other. TypeScript says what is impossible. A linter says what is risky.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Linter
- A tool that reads your code and reports risky habits, without running it. ESLint is the common one.
- Rule
- One check a linter does, with a name like no-floating-promises. You switch rules on, off, or to warning.
- Config
- A ready-made list of rules to switch on together, such as recommended or strictTypeChecked.
- Type-aware rule
- A rule that asks the compiler for the real type of a value before deciding. It is slower but catches more.
- Floating promise
- A promise that is started and then ignored: nobody awaits it or handles its errors.
- Unsafe any
- Using a value whose type is any, which switches type checking off for everything derived from it.
- projectService
- A typescript-eslint setting that finds the right tsconfig.json for each file so type-aware rules can work.
- npm alias
- Installing a package under another name, for example typescript pointing at @typescript/typescript6.
- tsc6
- The command that @typescript/typescript6 adds, so TypeScript 6.0 can sit beside the 7.0 tsc.
Step by step
01What the compiler allows and a linter flags
Take six lines: an async function that logs, and a register function that takes data: any, calls saveUser(data.name) and does not wait for it.
tsc is silent: every line is valid TypeScript. That is the gap a linter fills.
async function saveUser(name: string): Promise<void> {
console.log("saving", name);
}
export function register(data: any) {
saveUser(data.name);
}02Configure typescript-eslint (flat config)
ESLint 10 uses the *flat config* file eslint.config.js. defineConfig comes from eslint/config. tseslint.configs.strictTypeChecked is the strict set including type-aware rules. projectService: true is what lets those rules see types.
This is the exact file used in the run on the next step (ESLint 10.12.0, typescript-eslint 8.71.1).
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig(
tseslint.configs.strictTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
},
);03Run it and read the report
Each line is line:column, error, the message, and the rule name at the end. The rule name is what you search for, and what you turn off if you disagree.
Five problems in six lines: the async function has nothing to await, any appears, the promise floats, and the two uses of data are unsafe.
$ npx eslint src
src/save.ts
1:1 error Async function 'saveUser' has no 'await' expression @typescript-eslint/require-await
5:32 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
6:3 error 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 @typescript-eslint/no-floating-promises
6:12 error Unsafe argument of type `any` assigned to a parameter of type `string` @typescript-eslint/no-unsafe-argument
6:17 error Unsafe member access .name on an `any` value @typescript-eslint/no-unsafe-member-access
✖ 5 problems (5 errors, 0 warnings)04Fix by changing the code, not the rule
Replace any with a real type, await the promise, and the report is empty. When one line must break a rule on purpose, write void saveUser(...) for a promise or an eslint-disable-next-line rule-name -- reason comment, with the reason.
async function saveUser(name: string): Promise<void> {
await Promise.resolve(name);
}
export async function register(data: { name: string }): Promise<void> {
await saveUser(data.name);
}05Make it work with TypeScript 7
With only typescript@7.0.2 installed, ESLint stops before reading a file: typescript-eslint does not support TS 7.0. The message links to the announcement's side-by-side guide.
The fix, from that announcement, is two aliases in package.json. typescript becomes the 6.0 compatibility package (what typescript-eslint imports), and @typescript/native is TypeScript 7.0 whose tsc still runs as npx tsc. After npm install, the scratch project had both commands. The package behind the typescript alias, @typescript/typescript6, also adds a tsc6 command for the 6.0 compiler.
{
"devDependencies": {
"@typescript/native": "npm:typescript@7.0.2",
"typescript": "npm:@typescript/typescript6@6.0.2",
"eslint": "10.12.0",
"typescript-eslint": "8.71.1"
}
}06Check which compiler each command uses
After the aliases, npx tsc is TypeScript 7 (fast, for checking and building) and the 6.0 API sits underneath for ESLint. When typescript-eslint adds TypeScript 7 support (its tracking issue is linked from the error), you can remove the alias.
$ npx tsc --version
Version 7.0.2
$ npx tsc6 --version
Version 6.0.3
$ npm view typescript-eslint peerDependencies
{ eslint: '^8.57.0 || ^9.0.0 || ^10.0.0', typescript: '>=4.8.4 <6.1.0' }Try it yourself
- 1
Silence the lint, keep the meaning
In the first example, change
saveUser(name);tovoid saveUser(name);. Predict the output first: the order is the same, only the intent is now written down.Then change it to
await saveUser(name);and compare the logs. - 2
Turn the typo into a compile error
In the second example, change
const loose = JSON.parse(raw);toconst loose: unknown = JSON.parse(raw);. The Types panel now reports an error on the next line. The typo is found before running. - 3
Count your anys
Find a file of your own and count the words
anyin it:grep -c ": any" file.ts. Decide on a budget for the whole project and write it down. Topic 15.4 turns that number into a CI check.
Code & diagrams
tsc reports nothing here. The bug is the order: register finishes before saveUser does, and an error in saveUser would never reach the caller. Add await in register to fix it, or write void saveUser(name) to say it is intentional.
Console
(nothing was printed)Expected console output
right after: registered asha
later: registered asha | saved asha
safe version: saved ravi | registered raviJSON.parse returns any, so loose.usr (a typo) is accepted and everything after it goes unchecked. no-unsafe-member-access would flag this very line. Run it: the typo fails at runtime, which is the bug a linter would have found first.
Console
(nothing was printed)Expected console output
Uncaught TypeError: Cannot read properties of undefined (reading 'name')With unknown the compiler itself refuses unchecked use (the last line), so you narrow first, as in Topic 4.7. The type guard hasUserName is the checked door. The last line is the error; the console still prints because types are erased.
Console
(nothing was printed)Expected console output
ASHA
{ name: 'Asha' }The ! tells the compiler "trust me, not undefined". It is right the first time and wrong the second, and tsc cannot tell. The strict-type-checked config includes no-non-null-assertion, so a reviewer sees every one. Replace the ! with an if or ?.
Console
(nothing was printed)Expected console output
ASHA
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Let a promise float
In the scratch project, make src/ok.ts three lines of export async function saveUser(name: string): Promise<void> { await Promise.resolve(name); } (written over lines 1 to 3), then a register function on lines 4 to 6 whose only statement, on line 5, is saveUser(name); with no await. Run npx eslint src/ok.ts. The second error appears because register is async but now has nothing to await.
Break #2
Install TypeScript 7 only
In a project with typescript@7.0.2 and typescript-eslint@8.71.1, run npx eslint src.
Myth vs fact
Myth
If tsc passes, a linter adds nothing.
Fact
tsc accepts a forgotten await, a leaked any and a ! that lies. These are legal TypeScript. A linter flags the habits that cause real bugs.
Myth
Type-aware lint rules are as fast as plain ones.
Fact
They need the compiler's view of your program, so they cost more time. Run them in CI and the editor, and use projectService so the setup stays small.
Myth
Turning a rule off is how you fix it.
Fact
A rule that fires is usually pointing at a real risk. Change the code, or disable one line with a written reason.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The recommended starting point is
tseslint.configs.recommendedTypeChecked. Move tostrictTypeCheckedwhen the team is ready;stylisticTypeCheckedis separate and is only about taste. The names are exactly as exported by typescript-eslint 8.71.1:recommended,recommendedTypeChecked,strict,strictTypeChecked,stylistic,stylisticTypeCheckedand their...Onlyforms. - ▸
typescript-eslint's
config()helper is marked deprecated in favour of ESLint's owndefineConfig()(it says so in its type declarations), which is why the config above importsdefineConfigfromeslint/config. - ▸
ESLint's type-aware rules build a TypeScript program in memory. TypeScript 7's native speed does not speed this part up while typescript-eslint uses the 6.0 API, so linting can become the slowest check; run
tscandeslintas separate parallel CI jobs. - ▸
@typescript-eslint/no-unsafe-*rules exist becauseanyis contagious. The rule that closes the biggest door isno-explicit-anyplusno-unsafe-assignment: with both on,JSON.parseresults can't flow into typed code without a check. Topic 16.5 lists the other holes in the type system.
Remember this
- 1
The problem this solves: the compiler only answers "is this valid TypeScript, and do the types fit?". Plenty of code is valid and still a bug waiting to happen: a promise you forgot to wait for, an
anythat quietly switches checking off. A linter reads the code for those habits and reports them before a user finds them. - 2
Recall before you start. Topic 1.6:
anyturns checking off andunknownmakes you check first. Topic 4.7: a function with the return typev is T(a type guard) narrows a value inside anif. Topic 13.3: anasyncfunction always returns aPromise, andawaitwaits for it. - 3
ESLint is the most used JavaScript linter: it reads your files and applies rules, each a small check with a name such as
no-floating-promises. typescript-eslint is the project that teaches ESLint to read TypeScript. It gives you a parser, a set of rules, and ready-made configs (lists of rules) to switch on together. - 4
Rules come in two kinds. Plain rules look at the shape of the code alone (
no-explicit-anyflags the wordany). Type-aware rules ask the compiler for the real type of each expression, so they know a call returns aPromiseeven though no one wrotePromisenearby. To allow that you setparserOptions.projectService: true, which makes typescript-eslint find the righttsconfig.jsonfor each file (the live editor here can't run ESLint, but the examples below show the TypeScript behind each rule). - 5
**
no-floating-promises** is the rule with the best return on effort. A *floating* promise is one that is started and then ignored: nobody waits for it and nobody handles its failure. In the first live exampleregisterstartssaveUserwithoutawait, so "registered" is logged before "saved", and if saving fails the error vanishes into an *unhandled rejection* (a failed promise that no code handles, which Node and browsers report as an error). The rule asks you toawaitit, return it, add.catch(...), or writevoidin front to say "I know, on purpose".tscaccepts all of it; only a type-aware rule knowssaveUser(...)returns aPromise. - 6
**
no-explicit-any** flags the wordany, and in thestrict-type-checkedconfig its relatives (no-unsafe-argument,no-unsafe-member-access,no-unsafe-assignment,no-unsafe-call,no-unsafe-return) flag using a value whose type isany. This matters becauseanyspreads:JSON.parsereturnsany, and every value derived from it silently stops being checked (second live example). The fix isunknownplus narrowing (third example), which Topic 1.6 introduced. - 7
typescript-eslint ships configs in strictness steps.
recommendedis a small set of obvious bugs.strictadds more opinionated rules. Each has a-type-checkedversion that adds the type-aware rules, and astylisticfamily for taste (for example preferringinterface). Beginners start withrecommendedTypeChecked; teams that want the full safety net usestrictTypeChecked. The scratch run below usedstrictTypeCheckedand found five problems in six lines. - 8
TypeScript 7 and typescript-eslint. TypeScript 7.0 has no JavaScript compiler API yet (it is expected in 7.1), and typescript-eslint is built on that API, so it needs the 6.0 compiler.
npm view typescript-eslint peerDependencies(a *peer dependency* is the range of versions of another package that it works with) saystypescript: '>=4.8.4 <6.1.0'for version 8.71.1, and importing it with TypeScript 7 installed stops withtypescript-eslint does not support TS 7.0.The official fix is to run both side by side with npm aliases (the announcement's recommendation):typescriptpoints at@typescript/typescript6for tools that import it, and a second alias gives you the fast 7.0tsc. Both are in the terminal block below, run for real. - 9
A linter is also how a team enforces *a budget* instead of a ban. With
no-explicit-anyset to"warn",eslint --max-warnings 3fails the run only when the number ofanygrows past 3. Topic 15.4 uses this to migrate a JavaScript codebase step by step.
Explain it without notes
What does a linter do that the compiler does not?
What is a floating promise and why does tsc allow it?
What is the difference between no-explicit-any and the no-unsafe-* rules?
Why does typescript-eslint not work with plain TypeScript 7.0, and how do you fix it?
Practice
Write an async loadAll(ids: string[]) that calls loadOne(id) for every id and returns when all have finished, with no floating promises. (loadOne returns Promise<string>.)
Rewrite function parse(input: any): any { return JSON.parse(input); } so that no any is left, returning a User only when the data has a string name.
Trade-offs
- ↔
Strict lint configs cost setup time and some false alarms, and type-aware rules slow linting. They pay back in review time: a human no longer has to spot every missing
await. - ↔
Two compilers side by side (7.0 for
tsc, 6.0 for tools) is a temporary tax. Keep the aliases in one place and revisit them when typescript-eslint supports TypeScript 7.
Done when you can
I can say what a linter catches that
tscdoes not.I can configure typescript-eslint with
projectServiceand a-type-checkedconfig.I can explain a floating promise and fix it three ways.
I can replace
anywithunknownplus a type guard.I can explain why TypeScript 7 needs the
@typescript/typescript6alias for typescript-eslint.