Linting and Formatting
Type-aware lint rules that catch what the compiler allows, separating formatting from correctness, and configuring both so they never disagree.
Type-aware lint rules that catch what the compiler allows, separating formatting from correctness, and configuring both so they never disagree.
The pull request has nineteen comments. Four are about the bug.
The rest are import order, a missing await, an any somebody
noticed, and whether the object needs a trailing comma. The
reviewer spent their attention on that and had little left for
the logic.
Every one of those fifteen could have been raised by a program, before the review, for free. By the end of this lesson your project will format itself, catch a class of bug the type checker cannot, and enforce both where they cannot be skipped.
They are frequently confused, and they do not overlap:
The type checker
Is this type-correct? It proves photo.size is a number.
The formatter
Where do the line breaks go? It decides whether the argument list wraps.
The linter
Is this a pattern that causes bugs? It notices you called an async function without awaiting it — which is type-correct, correctly formatted, and a bug.
Keeping them separate matters. A formatter that argued about correctness would be dangerous; a linter that reformatted would produce enormous diffs.
Bad — a style guide people are asked to follow.
## Code style
- Two-space indentation
- Single quotes, except when the string contains one
- Trailing commas in multi-line literals
- Max line length 100Good — a config that applies it.
// .prettierrc
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80
}A written convention is enforced by humans reading diffs, which means inconsistently, and violated most by whoever is busiest. Automating it removes the enforcement work and — the part people underrate — removes the decisions. Every minute spent choosing where to break a line is a minute not spent on the problem.
Which formatter matters far less than that there is one.
Prettier is the default; Biome and dprint are faster. The one
real rule is that it runs automatically, because a formatter
people must remember to run produces diffs full of reformatting
noise from whoever ran it last.
Format on save, in the editor. Then nobody thinks about it again.
Lint rules divide into two kinds, and only one is interesting.
Stylistic rules — spacing, quote style, brace placement — are the formatter's job. Turn them off in the linter; running both produces conflicts.
Type-aware rules are the valuable ones. They use the type checker to see things no syntactic rule could:
They need the checker wired in:
That makes linting slower — it type-checks — and it is what makes these rules possible at all.
Three groups are worth understanding.
no-floating-promises catches the missing await from the
promises lesson. This is the single most valuable rule in the
list: an unawaited promise reports success before the work is
done and loses the error if it fails.
The five no-unsafe-* rules find any flowing through your
code from untyped dependencies — the second-hand any that
noImplicitAny does not catch. Most any in a codebase was
never written by anyone, and these are the only way to see it.
switch-exhaustiveness-check is the assertNever pattern
as a rule, applied to every switch on a union without needing a
default branch.
A handful more correspond directly to earlier lessons:
prefer-nullish-coalescing is the falsy-zero bug from the
foundations course as a rule — it flags || where ?? was
meant. no-non-null-assertion flags the ! that compiles and
crashes. The eqeqeq exception for null allows the one
legitimate == this course recommended.
Start from a shared config rather than assembling this by hand:
Then remove what does not fit. As with the strict-mode lesson: a first run reporting four thousand problems gets the tool removed, not the problems fixed. Enable one group at a time.
Three places, doing three different jobs.
The editor, where feedback is immediate and format-on-save means layout is never a decision.
A pre-commit hook, on changed files only:
Keep this fast. A hook that takes thirty seconds teaches people
to use --no-verify, which is worse than not having it. Type
checking and tests belong in CI, not here.
CI, because hooks can be skipped:
--check rather than --write: in CI you want a failure
telling the author to run the formatter, not a bot committing to
their branch.
Separate steps, so the log names what failed rather than giving one red cross and a scroll.
Formatting the whole repository in one commit creates a diff
that touches everything and buries every earlier line in
git blame. Two things fix that.
git blame then skips that commit, and GitHub reads the file
automatically. Do the formatting commit on its own, with no
other change in it.
For lint rules, the incremental path is the same as for strict
mode: set new rules to warn, fix them over time, then promote
to error — and gate only changed files initially if the
backlog is large.
Your project can now format itself and catch a class of bug the
checker cannot see. The specific thing to do today is enable
no-floating-promises and the five no-unsafe-* rules — the
first catches missing awaits, and the rest reveal how much any
is flowing through code you believed was checked.
Next is Building and Bundling, which covers turning your source into something you can ship: what the compiler emits, what a bundler adds, and why source maps decide whether a production stack trace is useful.
Before you move on, enable @typescript-eslint/no-floating-promises
on one directory and run it. In any codebase with async code,
the hits are places that report success before the work is done
— and there are usually more than anyone expects.
# .git-blame-ignore-revs
a1b2c3d4e5f6...THREE JOBS - do not overlap them
type checker is this type-correct?
formatter where do the line breaks go?
linter is this a pattern that causes bugs?
FORMATTING
a config file, not a style guide in a README
which formatter matters far less than having one
format on save; --check in CI, never --write
turn stylistic rules OFF in the linter - they conflict
TYPE-AWARE LINT RULES - the valuable ones
parserOptions: { projectService: true } (slower; required)
no-floating-promises <- the most valuable rule there is
no-misused-promises, await-thenable
no-unsafe-assignment / -member-access / -call / -return
/ -argument <- finds the any you INHERITED
switch-exhaustiveness-check <- assertNever, as a rule
RULES THAT ENCODE EARLIER LESSONS
prefer-nullish-coalescing || where ?? was meant
no-non-null-assertion the ! that compiles and crashes
consistent-type-imports import type
eqeqeq with { null: "ignore" }
ADOPTION
start from strictTypeChecked, then remove what does not fit
never all at once - 4,000 problems gets the tool removed
new rules as "warn", promote to "error" when clear
a rule people disable without reading is worse than none
WHERE IT RUNS
editor immediate; format on save
pre-commit changed files only, and FAST - or people use
--no-verify
CI because hooks can be skipped; separate steps
ADOPTING FORMATTING
one formatting-only commit
add its hash to .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs{
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/no-unsafe-argument": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/no-unnecessary-condition": "warn"
}
}// eslint.config.js
export default [
{
languageOptions: {
parserOptions: { projectService: true },
},
},
];{
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/require-await": "error",
"eqeqeq": ["error", "always", { "null": "ignore" }]
}import tseslint from "typescript-eslint";
export default tseslint.config(
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{ languageOptions: { parserOptions: { projectService: true } } },
);{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"]
}
}- run: npm ci
- run: npx prettier --check .
- run: npx eslint .
- run: npx tsc --noEmit
- run: npx vitest runnpx prettier --write .
git commit -m "style: format with prettier"git config blame.ignoreRevsFile .git-blame-ignore-revs