Linting with typescript-eslint

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

Linting with typescript-eslint means adding TypeScript-aware code-quality rules beside the compiler. tsc proves your program is type-consistent; a linter enforces team policy about risky code the compiler intentionally allows.

This lesson is tool-shaped, so there is no runnable playground. The examples are read-only config and reference code: the kind you would put in a real repository next to the Section 16 typecheck and test scripts.

Type-aware linting

Plain ESLint can read JavaScript syntax and catch local patterns: unused variables, unreachable code, suspicious expressions. typescript-eslint adds a TypeScript parser, TypeScript-specific rules, and an optional typed mode where rules can ask the TypeScript checker what an expression's type is.

That last part is the upgrade. A typed lint rule does not merely see this text:

persistProfile(profile);

It can ask, "what does persistProfile(profile) return?" If the checker answers Promise<void>, the rule can enforce your codebase's async policy. Syntax alone cannot know that.

The modern flat-config shape looks like this:

import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
 
export default defineConfig({
  files: ["**/*.{ts,tsx}"],
  extends: [
    js.configs.recommended,
    tseslint.configs.recommendedTypeChecked,
  ],
  languageOptions: {
    parserOptions: {
      projectService: true,
    },
  },
  rules: {
    "@typescript-eslint/no-floating-promises": "error",
    "@typescript-eslint/no-unnecessary-condition": "warn",
    "@typescript-eslint/switch-exhaustiveness-check": "error",
  },
});

recommendedTypeChecked turns on recommended rules that need type information. projectService: true tells typescript-eslint to use TypeScript's project service to find type information for each source file.

That costs more than syntax-only linting because TypeScript has to understand the project. In exchange, you get rules that can catch bugs the compiler does not treat as type errors.

The rules that pay rent

Start small. A dozen strict rules added at once creates noise. These three rules earn their place because they connect directly to bugs this course has already taught.

@typescript-eslint/no-floating-promises catches promise-valued expressions that are not handled. Section 14 showed why floating promises are dangerous: work starts, the surrounding function continues, and rejections may be ignored or reported too late.

Reference code:

type Profile = {
  id: string;
  displayName: string;
};
 
declare function persistProfile(profile: Profile): Promise<void>;
declare function showToast(message: string): void;
 
async function submitProfile(profile: Profile): Promise<void> {
  persistProfile(profile);
  showToast("Saved");
}

The compiler allows this because calling an async function for its side effect is legal JavaScript. The lint rule asks for an explicit choice: await it, return it, attach a rejection handler, combine promise arrays with Promise.all, or mark an intentional fire-and-forget call with void.

@typescript-eslint/no-unnecessary-condition catches checks that the checker already knows are always truthy, always falsy, or always unnecessary. It is especially useful after Section 15's nullability work, because stale defensive code can hide a changed contract.

Reference code:

type Lesson = {
  slug: string;
  title: string;
};
 
function labelFor(lesson: Lesson): string {
  if (lesson) {
    return lesson.title;
  }
 
  return "Untitled";
}

lesson is not optional, nullable, or unknown. That if branch once may have been useful, but the current type says it cannot fail. The rule turns that drift into a visible warning instead of letting everyone read dead caution as meaningful logic.

@typescript-eslint/switch-exhaustiveness-check catches missing cases in switch statements over literal unions or enums. Section 15 used assertNever inside a default branch. This rule complements that pattern by flagging missing cases directly at switch sites.

Reference code:

type PublishState = "draft" | "scheduled" | "published";
 
function actionFor(state: PublishState): string {
  switch (state) {
    case "draft":
      return "Edit";
    case "published":
      return "View";
  }
}

When "scheduled" enters the union, every decision site should make a deliberate choice. The linter can report that missing case even before a reviewer notices that the table of states changed.

Severity and autofix

Rule severity is a product decision, not a moral scale.

Use "error" when the rule protects correctness and should fail CI. no-floating-promises and switch-exhaustiveness-check usually belong here because a miss can create stale writes, ignored failures, or unhandled product states.

Use "warn" when the rule is useful but you expect cleanup during adoption. no-unnecessary-condition is often a good warning first: it finds real dead checks, but older projects may have many of them.

Use "off" when a rule does not match the codebase yet. Turning a noisy rule off is better than training the team to ignore red output.

Scripts make the policy visible:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "lint": "eslint . --max-warnings=0",
    "lint:fix": "eslint . --fix",
    "test:run": "vitest run",
    "quality": "npm run typecheck && npm run lint && npm run test:run"
  }
}

--max-warnings=0 is deliberately strict: warnings still fail the one-shot lint command. If that is too strict during migration, remove it temporarily and set a date to bring it back.

Autofix is for mechanical changes. eslint . --fix can apply fixes for rules that declare safe rewrites, and editors may offer suggestions for other rules. Do not treat autofix as a substitute for intent. If a rule says a promise is floating, the fix is not "make the text quiet"; the fix is deciding whether that work must be awaited, returned, caught, batched, or explicitly launched in the background.

Where Biome fits

Biome is the fast all-in-one tool many teams reach for when they want formatting, linting, and import organization with one command:

npx @biomejs/biome check --write ./src

That can be a great project-hygiene move. A single fast command reduces style debates, handles many common lint fixes, and can organize imports without adding three separate tools.

Keep the boundary honest: Biome is nearby, not a drop-in replacement for every typed ESLint rule. If your codebase depends on checker-backed rules like no-floating-promises, no-unnecessary-condition, and switch-exhaustiveness-check, keep typescript-eslint for those policies or verify that your chosen Biome rules cover the same risks in your codebase.

A common mature setup is:

npx @biomejs/biome check --write ./src
npm run lint
npm run typecheck

Biome handles fast hygiene. typescript-eslint enforces TypeScript-aware policy. tsc --noEmit remains the project-wide type gate.

Next lesson stays in the toolchain and moves from code-quality policy to formatting and project hygiene: ending style debates, sorting imports, and keeping setup files boring enough to trust.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion