EthicsComputerCourses
Join training +91 90532 10052

Linting with typescript-eslint

TypeScript · Lesson 39 of 42 Advanced 7 min read Updated

The TypeScript compiler answers one question: "do the types fit?" Many bugs pass that test. A promise that is never awaited, any values spreading through the code, a switch that silently misses a new case, an import that should be import type. A linter is a second tool that checks your code against rules for such patterns.

ESLint is the standard JavaScript linter, and typescript-eslint is the project that lets ESLint read TypeScript and adds rules that use type information. Nearly every professional TypeScript codebase runs it in the editor and in CI. In this lesson you will set it up, understand the presets, and read the most valuable rules.

Installing ESLint and typescript-eslint

Install three development packages:

Terminal
npm install --save-dev eslint @eslint/js typescript-eslint

There is one TypeScript 7 detail. typescript-eslint's type-aware rules call the TypeScript compiler as a library, and TypeScript 7.0 has no such API yet (see TypeScript 7 Compiler & Upgrading). The typescript-eslint 8 release used for this lesson (8.72.1) declares support for TypeScript below 6.1. So in a TypeScript 7 project, let typescript resolve to the TypeScript 6 compatibility package and get the TypeScript 7 compiler under another name:

JSON
{
  "devDependencies": {
    "typescript": "npm:@typescript/typescript6@^6.0.2",
    "@typescript/native": "npm:typescript@^7.0.2"
  }
}

Your tsc command still runs TypeScript 7, while ESLint uses TypeScript 6 behind the scenes. Check the typescript-eslint website for the current status; this step disappears once it supports the new API.

The config file

ESLint uses a flat config file, eslint.config.mjs, at the project root. This one turns on ESLint's own recommended rules plus typescript-eslint's type-aware recommended rules:

JavaScript
// @ts-check
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";

export default defineConfig(
  eslint.configs.recommended,
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
      },
    },
  },
  {
    files: ["**/*.mjs"],
    extends: [tseslint.configs.disableTypeChecked],
  },
);

projectService: true tells typescript-eslint to find the right tsconfig.json for each file the same way your editor does. The last block switches type-aware rules off for plain JavaScript config files, which are not part of your TypeScript project. The // @ts-check comment at the top makes your editor check the config file itself.

Run it with npx eslint src, or add "lint": "eslint ." to the scripts in package.json.

What type-aware rules catch

Here is a small checkout function. It compiles without a single TypeScript error:

TypeScript
type Order = { id: string; total: number };

async function saveOrder(order: Order): Promise<void> {
  await new Promise((r) => setTimeout(r, 10));
  console.log(`saved ${order.id}`);
}

export function checkout(raw: string): void {
  const data: any = JSON.parse(raw);
  const order: Order = data;
  saveOrder(order);
  [order].forEach(async (o) => await saveOrder(o));
}

ESLint with recommendedTypeChecked finds five problems:

Output
src/orders.ts
   9:9   error  Unsafe assignment of an `any` value                                                                                                                                 @typescript-eslint/no-unsafe-assignment
   9:15  error  Unexpected any. Specify a different type                                                                                                                            @typescript-eslint/no-explicit-any
  10:9   error  Unsafe assignment of an `any` value                                                                                                                                 @typescript-eslint/no-unsafe-assignment
  11: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
  12:19  error  Promise returned in function argument where a void return was expected                                                                                              @typescript-eslint/no-misused-promises

✖ 5 problems (5 errors, 0 warnings)

(ESLint prints the full path of each file; it is shortened in this lesson.) Each rule maps to a real production bug:

  • no-explicit-any and no-unsafe-assignment: JSON.parse returns any, and assigning it to Order turns off checking without anyone noticing. Parse into unknown and validate instead (see Runtime Validation & Type-Safe APIs).
  • no-floating-promises: saveOrder(order); starts the save but nobody waits for it or handles its failure. If the database is down, the error disappears.
  • no-misused-promises: forEach ignores the promise an async callback returns, so the loop does not wait (you saw this in Typing Async Code).

These rules need type information, which is why they are called type-aware. A plain syntax linter cannot know that saveOrder returns a promise.

Choosing presets

typescript-eslint ships ready-made rule sets. Pick one row from each column of this table:

PresetRules
recommended / recommendedTypeCheckedRules for clear bugs and bad practice; a safe start for any project
strict / strictTypeCheckedEverything in recommended plus more opinionated rules that catch subtler bugs
stylistic / stylisticTypeCheckedConsistent style, such as one way to define object types; no bug-finding

The ...TypeChecked versions include every rule from the plain version plus the type-aware ones. Teams starting a new project often choose strictTypeChecked and stylisticTypeChecked. For an existing codebase, start with recommendedTypeChecked so the first run does not report thousands of problems.

You can also turn on individual rules in a rules block. Two that are not in the presets but are worth adding:

JavaScript
rules: {
  "@typescript-eslint/switch-exhaustiveness-check": "error",
  "@typescript-eslint/consistent-type-imports": "error",
},

With the strict and stylistic presets plus these two rules, this fare module gets three reports:

TypeScript
// src/types.ts
export type Coach = "SL" | "3A" | "2A" | "CC";
export type Passenger = { name: string; age: number };

// src/fare.ts
import { Coach, Passenger } from "./types.js";

export function baseFare(coach: Coach): number {
  switch (coach) {
    case "SL":
      return 450;
    case "3A":
      return 1150;
    case "2A":
      return 1650;
  }
  return 0;
}
Output
src/fare.ts
  1:1   error  All imports in the declaration are only used as types. Use `import type`  @typescript-eslint/consistent-type-imports
  4:11  error  Switch is not exhaustive. Cases not matched: "CC"                         @typescript-eslint/switch-exhaustiveness-check

src/types.ts
  2:13  error  Use an `interface` instead of a `type`  @typescript-eslint/consistent-type-definitions

✖ 3 problems (3 errors, 0 warnings)
  2 errors and 0 warnings potentially fixable with the `--fix` option.

The missing "CC" (AC Chair Car) case is a real bug. The final return 0 keeps TypeScript quiet, so a chair-car ticket would silently cost nothing:

TypeScriptRun it yourself
type Coach = "SL" | "3A" | "2A" | "CC";

function baseFare(coach: Coach): number {
  switch (coach) {
    case "SL":
      return 450;
    case "3A":
      return 1150;
    case "2A":
      return 1650;
  }
  return 0;
}

for (const c of ["SL", "CC"] as const) {
  console.log(`${c}: ₹${baseFare(c)}`);
}
Output
SL: ₹450
CC: ₹0

The consistent-type-definitions report is a style choice: the stylistic preset prefers interface for object types. If your team prefers type, configure the rule as ["error", "type"]. Style rules should match a team decision, not the other way round.

Living with a linter

  • Auto-fix. npx eslint . --fix applies safe fixes, such as converting to import type. Review the diff before committing.
  • Disabling a rule once. Write // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- reason above the line. Always name the rule and give a reason after --.
  • CI. Run npm run lint in your pipeline next to tsc --noEmit, so problems are caught before review.
  • Speed. Type-aware linting is slower than type checking alone, because it builds type information too. On large repos, lint changed files locally and the whole project in CI.

Common mistakes

  • Using the type-aware presets without projectService. ESLint stops with an error saying the rules need type information.
  • Silencing rules everywhere with eslint-disable. Each comment hides a possible bug. Fix the code or change the rule in config.
  • Treating style rules as bugs. Agree on stylistic rules as a team; keep bug-finding rules strict.
  • Linting build output. Add dist and other generated folders to an ignores entry in the config.

Key points

  • ESLint with typescript-eslint catches bugs that type checking alone misses, such as floating promises and unsafe any.
  • The flat config eslint.config.mjs combines @eslint/js rules with typescript-eslint presets; projectService: true enables type-aware rules.
  • Choose recommendedTypeChecked for existing code and strictTypeChecked plus stylisticTypeChecked for new projects.
  • switch-exhaustiveness-check and consistent-type-imports are valuable extra rules.
  • In a TypeScript 7 project, typescript-eslint currently uses TypeScript 6 through the @typescript/typescript6 alias.

Exercise

A Jaipur handicrafts shop's code has these lines: const items: any = await res.json();, items.forEach(async (i) => await updateStock(i)); and sendInvoice(orderId); where updateStock and sendInvoice return promises. For each line, name the typescript-eslint rule that reports it and rewrite the line so the rule passes.

Show solution
TypeScript
// no-explicit-any + no-unsafe-assignment: parse into unknown, then validate
const raw: unknown = await res.json();
const items = parseItems(raw); // your schema or type guard returns Item[]

// no-misused-promises: forEach ignores promises; wait for all updates
await Promise.all(items.map((i) => updateStock(i)));

// no-floating-promises: await it (or handle errors explicitly)
await sendInvoice(orderId);

Each fix makes the hidden behaviour visible: any is replaced by checked data, the stock updates are really awaited, and a failed invoice now surfaces as an error instead of disappearing.

Quick Quiz

1. Why does no-floating-promises need type information?

2. What does projectService: true do in the config?

3. Which preset should you start with in a large existing codebase?

4. Why does a TypeScript 7 project need the @typescript/typescript6 alias for type-aware linting today?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call