Skip to Content
TypeScript Advanced: Types That Hold Up in Production
Advanced
0 likes
Courses
TypeScript Advanced: Types That Hold Up in Production

typescript-eslint Rules That Catch Real Mistakes

Set up typescript-eslint with type information on TypeScript 7 and turn on four rules that find bugs the compiler allows. After this, you can add a rule to a repository one at a time and know which mistakes it catches.

Course content

Sign in to save your progress.

Course Home

Skip ahead: If you already run typescript-eslint with type information and only want the rule list, skip to "Four rules worth turning on", then continue.

tsc answers one question: do the types agree? It does not ask whether you forgot to wait for a promise, or whether the object you got from JSON.parse is being trusted blindly, because both of those are legal TypeScript. A linter that can read your types can ask those questions. This lesson is not about style. Formatting rules and naming rules are fine, but they never found a bug. The few rules below do, and they work only when the linter has type information.

Flat config basics, such as how eslint.config.mjs is organised and how to adopt a rule gradually, are in the JavaScript Advanced course. This lesson covers only what changes when the code is TypeScript.

The setup that works with TypeScript 7

There is one thing to know before you install anything. typescript-eslint reads your code through TypeScript's JavaScript API, and TypeScript 7.0 does not ship that API. The Go-native tsc is a separate program. The TypeScript team publishes a compatibility package, @typescript/typescript6, so that you can keep tsc 7 for building and give the linter the version 6 API. The recommended way is an npm alias.

{
  "name": "lintdemo",
  "private": true,
  "type": "module",
  "devDependencies": {
    "@typescript/native": "npm:typescript@7.0.2",
    "typescript": "npm:@typescript/typescript6@^6.0.2",
    "eslint": "10.12.0",
    "@eslint/js": "^10.0.1",
    "typescript-eslint": "8.71.2"
  }
}

The package named typescript is now the version 6 compatibility package, which is what typescript-eslint imports. @typescript/native is the real TypeScript 7.0.2, and it still puts a tsc command in node_modules/.bin. If you install plain typescript@7.0.2 next to typescript-eslint 8.71.2, npm refuses with an ERESOLVE error, because the plugin declares support for versions below 6.1. Force it with --legacy-peer-deps and it fails when ESLint starts, with "typescript-eslint does not support TS 7.0". Both messages are accurate, and the alias is the fix.

npm install --no-audit --no-fund --loglevel=error
npx tsc --version
npx tsc6 --version
Version 7.0.2
Version 6.0.3

Now the config. recommendedTypeChecked is the preset that uses type information, and projectService tells the plugin to find the right tsconfig for each file on its own.

{
  "compilerOptions": {
    "strict": true,
    "module": "nodenext",
    "target": "es2022",
    "noEmit": true,
    "types": []
  },
  "include": ["src"]
}
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  eslint.configs.recommended,
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      "@typescript-eslint/switch-exhaustiveness-check": "error",
    },
  },
);

A file that compiles and is still wrong

type Status = "open" | "closed" | "archived";

async function save(id: number): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, 10));
  console.log("saved", id);
}

export function label(status: Status): string {
  switch (status) {
    case "open":
      return "Open";
    case "closed":
      return "Closed";
  }
  return "Unknown";
}

export function run(ids: number[]): string {
  save(0);
  ids.forEach(async (id) => {
    await save(id);
  });
  const data = JSON.parse('{"name":"Ana"}');
  const name: string = data.name;
  return `${label("open")} ${name}`;
}
npx tsc --noEmit
echo "tsc exit code $?"
npx eslint src
tsc exit code 0

/path/to/project/src/orders.ts
   9:11  error  Switch is not exhaustive. Cases not matched: "archived"                                                                                                             @typescript-eslint/switch-exhaustiveness-check
  19: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
  20:15  error  Promise returned in function argument where a void return was expected                                                                                              @typescript-eslint/no-misused-promises
  23:9   error  Unsafe assignment of an `any` value                                                                                                                                 @typescript-eslint/no-unsafe-assignment
  24:9   error  Unsafe assignment of an `any` value                                                                                                                                 @typescript-eslint/no-unsafe-assignment
  24:29  error  Unsafe member access .name on an `any` value                                                                                                                        @typescript-eslint/no-unsafe-member-access

✖ 6 problems (6 errors, 0 warnings)

tsc is happy. ESLint reports six problems. Between them they cover four kinds of mistake, and each one is worth a rule of its own.

Four rules worth turning on

no-floating-promises. save(0) starts work and nobody waits for it. If it fails, the rejection has no handler. Either await it, return it, or write void save(0) to say that you meant to leave it running. The point is that the choice is visible.

no-misused-promises. forEach ignores what its callback returns, so an async callback is a promise that nobody can wait for. Run it and watch the order.

async function save(id: number): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, 10));
  console.log("saved", id);
}

async function main(): Promise<void> {
  [1, 2].forEach(async (id) => {
    await save(id);
  });
  console.log("done");
}

void main();
done
saved 1
saved 2

done is printed before either save finishes. A for...of loop with await, or await Promise.all(ids.map(save)), waits properly. The rule catches the same mistake in other places where a callback is expected to return void, such as an event handler.

no-unsafe-assignment. JSON.parse returns any, and const name: string = data.name quietly turns that any into a string annotation. The rule flags both assignments, and its neighbour no-unsafe-member-access flags data.name. The fix is the one from the Intermediate course: type the result as unknown and check it before use.

switch-exhaustiveness-check. label handles two of three statuses and falls through to "Unknown". tsc cannot complain, because the fallthrough return makes the function total. This rule is not in the recommended preset, so it was switched on in the config above. It names the case you missed: "archived". Add a new status next year and every switch over it lights up.

💡 Tip: Switch rules on one at a time and fix the whole repository for that rule before the next one. A hundred new errors from a preset help nobody. One rule with ten errors is a pull request.

Fix the file

type Status = "open" | "closed" | "archived";

async function save(id: number): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, 10));
  console.log("saved", id);
}

export function label(status: Status): string {
  switch (status) {
    case "open":
      return "Open";
    case "closed":
      return "Closed";
    case "archived":
      return "Archived";
  }
}

export async function run(ids: number[]): Promise<string> {
  await save(0);
  await Promise.all(ids.map(save));
  const data: unknown = JSON.parse('{"name":"Ana"}');
  const name = typeof data === "object" && data !== null && "name" in data ? String(data.name) : "";
  return `${label("open")} ${name}`;
}
npx tsc --noEmit
npx eslint src
echo "exit code $?"
exit code 0

Both are quiet. Note that label no longer needs the "Unknown" line, since every case returns and the compiler can see that.

Costs and limits

  • Type-aware rules are slower, because the linter builds the whole program, the same work tsc does. With TypeScript 7 you now have two compilers in the repository, one for the build and one for the linter, so a big project pays for both. Lint changed files in the editor and everything in CI.
  • A rule is a claim about your team's habits. no-floating-promises is right for almost everyone. no-unnecessary-condition is strict enough that it needs a conversation.
  • When a rule is wrong for one line, silence that line with // eslint-disable-next-line @typescript-eslint/no-floating-promises -- reason and write the reason. An unexplained disable is a bug that you postponed.

⚠ Warning: Keep typescript-eslint pinned to an exact version and read its release notes before you upgrade. The error message you get on TypeScript 7.0 points to the issue that tracks support for 7.1 and later, and until that lands the side-by-side setup is a bridge.

If you skip this, tsc will keep passing code that forgets to wait, trusts any and misses a case, and you will find the bugs in production logs. Which async function in your code is called without an await right now, and would you notice if it failed?

About

Set up typescript-eslint with type information on TypeScript 7 and turn on four rules that find bugs the compiler allows. After this, you can add a rule to a repository one at a time and know which mistakes it catches.

Duration
10 min
Last update
10/12/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.