TypeScript Tooling and Linting

The compiler is the core of the toolchain, but it is deliberately narrow: it asks "are these types consistent?", not "is this code maintainable?". Production teams wrap the checker with a linter that understands types, a formatter that removes style arguments, and a CI arrangement where none of it is optional. This lesson assembles that wrapper.

The quality pipeline

Four tools, each with a narrow mandate. Understanding the division of labor prevents the classic arguments about which tool "owns" a rule:

ToolMandateExample rule
tsctype consistency"string is not assignable to number"
typescript-eslintbugs + maintainability, type-awarefloating promises, unsafe any propagation, no non-null assertions
prettierformatting onlyindentation, quotes, line width — zero semantic rules
editorinstant feedbacksquiggles, quick fixes, rename refactors

Why ESLint still matters in a TypeScript project

The most common question from JavaScript developers: "doesn't the compiler make the linter redundant?" No — the checker verifies consistency, not intent. Two examples the compiler accepts happily:

Purpose of the example: two dangerous lines that type-check perfectly clean.

// 1. Floating promise: work launched, result discarded, failures invisible.
// The compiler sees a valid call; typescript-eslint's
// no-floating-promises sees an unhandled rejection waiting:
fetchTelemetry();

// 2. Non-null assertion: a lie you tell the compiler with no proof.
// If users[0] is undefined at runtime, this is a TypeError in production:
const first = users[0]!.name;

// The lint fixes push you toward the honest versions:
void fetchTelemetry();                       // intentional fire-and-forget
const first2 = users[0]?.name ?? "unknown";  // undefined handled explicitly

What you should see: with typescript-eslint configured with type-checking rules (no-floating-promises, noNonNullAssertion), both original lines are flagged. These rules need the type information — plain (non-type-aware) linting cannot see them, which is why the setup requires the parser to run on the TypeScript AST.

Formatting is solved — hand it to Prettier

Never encode style opinions in your linter; it produces rules that fight the formatter and reviews that argue about commas. Configuration is one file, and the CI gate is prettier --check .:

// .prettierrc — boring on purpose
{
  "printWidth": 100,
  "singleQuote": false,
  "trailingComma": "all"
}

What you should see: formatting diffs disappear from code reviews entirely, because the machine makes every formatting decision. Keep prettier and typescript-eslint from colliding by disabling ESLint's stylistic rules (the tooling section of typescript-eslint's docs lists the exact rule sets to switch off).

Monorepos and project references

Why one tsconfig stops scaling

As codebases grow, one giant tsconfig becomes slow and ambiguous: every file sees every other file's internals. TypeScript's answer is project references — multiple small tsconfigs with explicit dependency edges, checked and cached independently.

Purpose of the example: split an app into a shared library and an app that depends on it, with incremental builds.

// tsconfig.json — the solution file: it builds nothing itself
{
  "files": [],
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/web" }
  ]
}
// packages/shared/tsconfig.json — a leaf project
{
  "compilerOptions": {
    "composite": true,        // required for referenced projects;
    "declaration": true       // exposes .d.ts to dependents
  },
  "include": ["src"]
}

Incremental builds across packages

What you should see: npx tsc --build (note the flag: --build, not --noEmit) compiles shared first, then web, and on the second run rebuilds only what changed. Editing a file in shared that web imports gives a precise error across the boundary — with the public types, not internal files.

What we learn: boundaries you make explicit are boundaries the checker enforces. Project references are also the mechanism behind workspace monorepos (npm/pnpm workspaces, Turborepo): each package owns its tsconfig, and CI caches per project.

The CI gate in practice

Cheap checks first

Tools only create quality when CI makes them non-optional. A complete pipeline for a TypeScript project:

Purpose of the example: the exact script set CI expects — order matters, cheap checks first.

{
  "scripts": {
    "lint": "eslint . --max-warnings 0",
    "format:check": "prettier --check .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "verify": "npm run format:check && npm run lint && npm run typecheck && npm run test"
  }
}

Zero-warning discipline

What you should see: one npm run verify that any contributor can run locally with identical results to CI. --max-warnings 0 is the detail that keeps warning counts from ratcheting upward: warnings are errors, so the count can only stay flat or go down.

Practice: assemble the wrapper

Wire the pipeline into a real project

Take the strict project from the setup lesson and add typescript-eslint (type-checked config), Prettier, and the verify script. Then seed three violations on purpose: a floating promise, a non-null assertion, and a formatting inconsistency. Run npm run verify and fix each using the tool's own suggestion. What you should see: three failures, each with an autofix — and after the fix, a clean gate.

Measure the inner loop

Run npx tsc --noEmit --extendedDiagnostics on the project and read the phases: how long parsing, binding, and checking each take. Add "incremental": true to tsconfig and rerun. What we learn: the checker's cost is real and measurable, and this is the data that justifies project references when the project outgrows a single tsconfig.