Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~23 min· topic 2 of 6

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.

src/save.tswhole filets
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).

eslint.config.jswhole filets
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.

terminal (scratch project, ESLint 10.12.0)whole filebash
$ 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.

src/save.tswhole filets
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.

package.json (scratch project)whole filejson
{
  "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.

terminalwhole filebash
$ 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. 1

    Silence the lint, keep the meaning

    In the first example, change saveUser(name); to void 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. 2

    Turn the typo into a compile error

    In the second example, change const loose = JSON.parse(raw); to const loose: unknown = JSON.parse(raw);. The Types panel now reports an error on the next line. The typo is found before running.

  3. 3

    Count your anys

    Find a file of your own and count the words any in 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

A floating promise: the rule's reason

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 ravi
any leaks: the lint rules exist because of this

JSON.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')
unknown instead of any: the fix the rules push you toward

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 non-null assertion: banned by default in strict configs

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.

terminal
$ npx eslint src/ok.ts
── what you'll see ──
4:8 error Async function 'register' has no 'await' expression @typescript-eslint/require-await
5: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

Break #2

Install TypeScript 7 only

In a project with typescript@7.0.2 and typescript-eslint@8.71.1, run npx eslint src.

terminal
$ npx eslint src
── what you'll see ──
typescript-eslint does not support TS 7.0.
Please see https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0 to run typescript-eslint using the TS 6 API.
See also https://github.com/typescript-eslint/typescript-eslint/issues/10940 for tracking typescript-eslint's support for TS >=7.1

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 to strictTypeChecked when the team is ready; stylisticTypeChecked is separate and is only about taste. The names are exactly as exported by typescript-eslint 8.71.1: recommended, recommendedTypeChecked, strict, strictTypeChecked, stylistic, stylisticTypeChecked and their ...Only forms.

  • ▸

    typescript-eslint's config() helper is marked deprecated in favour of ESLint's own defineConfig() (it says so in its type declarations), which is why the config above imports defineConfig from eslint/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 tsc and eslint as separate parallel CI jobs.

  • ▸

    @typescript-eslint/no-unsafe-* rules exist because any is contagious. The rule that closes the biggest door is no-explicit-any plus no-unsafe-assignment: with both on, JSON.parse results can't flow into typed code without a check. Topic 16.5 lists the other holes in the type system.

Remember this

  1. 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 any that quietly switches checking off. A linter reads the code for those habits and reports them before a user finds them.

  2. 2

    Recall before you start. Topic 1.6: any turns checking off and unknown makes you check first. Topic 4.7: a function with the return type v is T (a type guard) narrows a value inside an if. Topic 13.3: an async function always returns a Promise, and await waits for it.

  3. 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. 4

    Rules come in two kinds. Plain rules look at the shape of the code alone (no-explicit-any flags the word any). Type-aware rules ask the compiler for the real type of each expression, so they know a call returns a Promise even though no one wrote Promise nearby. To allow that you set parserOptions.projectService: true, which makes typescript-eslint find the right tsconfig.json for each file (the live editor here can't run ESLint, but the examples below show the TypeScript behind each rule).

  5. 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 example register starts saveUser without await, 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 to await it, return it, add .catch(...), or write void in front to say "I know, on purpose". tsc accepts all of it; only a type-aware rule knows saveUser(...) returns a Promise.

  6. 6

    **no-explicit-any** flags the word any, and in the strict-type-checked config its relatives (no-unsafe-argument, no-unsafe-member-access, no-unsafe-assignment, no-unsafe-call, no-unsafe-return) flag using a value whose type is any. This matters because any spreads: JSON.parse returns any, and every value derived from it silently stops being checked (second live example). The fix is unknown plus narrowing (third example), which Topic 1.6 introduced.

  7. 7

    typescript-eslint ships configs in strictness steps. recommended is a small set of obvious bugs. strict adds more opinionated rules. Each has a -type-checked version that adds the type-aware rules, and a stylistic family for taste (for example preferring interface). Beginners start with recommendedTypeChecked; teams that want the full safety net use strictTypeChecked. The scratch run below used strictTypeChecked and found five problems in six lines.

  8. 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) says typescript: '>=4.8.4 <6.1.0' for version 8.71.1, and importing it with TypeScript 7 installed stops with typescript-eslint does not support TS 7.0. The official fix is to run both side by side with npm aliases (the announcement's recommendation): typescript points at @typescript/typescript6 for tools that import it, and a second alias gives you the fast 7.0 tsc. Both are in the terminal block below, run for real.

  9. 9

    A linter is also how a team enforces *a budget* instead of a ban. With no-explicit-any set to "warn", eslint --max-warnings 3 fails the run only when the number of any grows past 3. Topic 15.4 uses this to migrate a JavaScript codebase step by step.

Explain it without notes

01

What does a linter do that the compiler does not?

02

What is a floating promise and why does tsc allow it?

03

What is the difference between no-explicit-any and the no-unsafe-* rules?

04

Why does typescript-eslint not work with plain TypeScript 7.0, and how do you fix it?

Practice

01

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>.)

02

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 tsc does not.

  • I can configure typescript-eslint with projectService and a -type-checked config.

  • I can explain a floating promise and fix it three ways.

  • I can replace any with unknown plus a type guard.

  • I can explain why TypeScript 7 needs the @typescript/typescript6 alias for typescript-eslint.