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:
| Tool | Mandate | Example rule |
|---|---|---|
tsc | type consistency | "string is not assignable to number" |
typescript-eslint | bugs + maintainability, type-aware | floating promises, unsafe any propagation, no non-null assertions |
prettier | formatting only | indentation, quotes, line width — zero semantic rules |
editor | instant feedback | squiggles, 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.