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.
-
Types That Compute
-
Conditional Types and infer Without Nested Nightmares
-
Template Literal Types for Routes and Event Names
-
-
-
Brands and Boundaries
-
Branded Types So IDs Don't Mix
-
satisfies and const Assertions When Inference Isn't Enough
-
-
-
Declare What JavaScript Didn't
-
Writing .d.ts for a Plain JS Module
-
Ambient Modules and Global Augmentation Without Breaking Everything
-
-
-
Projects That Scale
-
Project References and Solution-Style tsconfig
-
typescript-eslint Rules That Catch Real Mistakes
-
-
-
Migrate and Keep Moving
-
Move a JavaScript Folder to TypeScript Without a Big Bang
-
allowJs, checkJs and Leaving JSDoc Behind
-
Keep Build Times Sane: incremental and What Actually Slows tsc
-
-
- Final Check: TypeScript Advanced
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
tscdoes. 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-promisesis right for almost everyone.no-unnecessary-conditionis 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 -- reasonand 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
Comments (0)
Sign in to commentNo comments yet.