Why Do Coding Standards Docs Rot?
Most coding standards docs describe a config that was accurate the month it was written, then drift silently out of sync with what the tooling actually enforces — until a new hire's PR fails CI for reasons the doc never mentions.
Every team writes a coding standards doc at some point. Most of them rot. They describe an ESLint config that was accurate the month it was written, get out of sync with whatever the dependencies actually enforce today, and nobody notices until a new hire's first PR fails CI for reasons the doc doesn't mention.
We took a different approach for our React projects. Instead of
writing a description of a setup, we built the setup, wrote down
every command and config file required to reproduce it from a blank
npm create vite@latest, and then proved it works by
scaffolding a small demo to-do app and running the full pipeline
against it — including a deliberately bad commit to confirm the
hooks actually catch something. The doc is the setup. If the doc is
wrong, the demo app's CI turns red.
Should Coding Standards Be Enforced by Git Hooks or CI?
CI should be the real enforcement gate, not git hooks — hooks run on a developer's machine and can be skipped with one flag, but a required CI check before merge can't be bypassed.
The first thing that has to be right in any standards setup is the
enforcement model, because everything else is negotiable and this
isn't. Git hooks run on a developer's machine. They can be skipped
with git commit -n, and on a long enough timeline,
someone will skip one under deadline pressure.
So the hooks are optimized for speed, not completeness. Pre-commit runs Prettier only — milliseconds, not a full type-aware lint pass — via a one-line Husky v9 hook:
echo "npx lint-staged" > .husky/pre-commit
No shebang, no . "$(dirname -- "$0")/_/husky.sh"
boilerplate — that pattern is deprecated in Husky v9 and breaks
outright in v10. If you see it in a hook file, delete it.
The actual gate is CI: npm ci →
npm run validate (type-check + lint + format:check) →
npm run build, required to pass before merge, with
branch protection on main. A skipped local hook costs
you nothing except a red PR five minutes later. That's the whole
enforcement philosophy in one sentence: make the fast path
convenient and the slow path unavoidable.
| Stage | What Runs | Can It Be Skipped? |
|---|---|---|
| Editor save | Prettier + ESLint auto-fix | Yes — if the extension isn't installed |
git commit |
Husky pre-commit → lint-staged (Prettier only)
|
Yes — git commit -n |
git push |
Husky pre-push → npm run validate (optional)
|
Yes |
| PR opened |
CI: npm ci → validate →
build
|
No — required to merge |
What Does Strict TypeScript Actually Catch?
noUncheckedIndexedAccess makes every array or object
index read type as T | undefined instead of
T, forcing out-of-bounds bugs to surface at compile
time instead of in production.
The tsconfig.app.json goes past the default
strict: true — it also turns on
noUncheckedIndexedAccess,
noPropertyAccessFromIndexSignature, and
verbatimModuleSyntax. That first one alone catches a
real class of bugs: array[i] types as T,
not T | undefined, by default, which means an
out-of-bounds read silently type-checks as valid. With the flag on,
the compiler forces you to handle the undefined case at
the call site instead of at runtime, in production, three months
later.
We standardized on exactly one path alias — @/* — and
explicitly ban a @types/* alias, because it shadows the
node_modules/@types convention and quietly confuses
editor tooling. Multiple aliases (@components/*,
@hooks/*, …) look tidy in a README but cost you three
places to keep in sync — tsconfig, Vite, ESLint import order — for
zero real benefit over @/components/....
What Breaks First When You Set Up ESLint 10 for React?
Two real gaps as of ESLint 10: eslint-plugin-react and
eslint-plugin-jsx-a11y still cap their peer ranges
below ESLint 10, and React's version: "detect"
setting crashes outright on ESLint 10's new rule-context API.
ESLint 10's flat config plus typescript-eslint's
strictTypeChecked gives type-aware linting — rules like
no-floating-promises and
prefer-nullish-coalescing that a syntax-only linter
can't express. But getting there exposed two ecosystem gaps worth
calling out, because they're the kind of thing that eats a whole
afternoon if you hit them cold:
Peer dependency lag. As of ESLint 10,
eslint-plugin-react and
eslint-plugin-jsx-a11y both still declare peer ranges
that stop at ESLint 9 —
tracked upstream in eslint-plugin-react#3977. A plain npm install — and npm ci, which
CI uses — fails immediately with ERESOLVE. The fix is
one file, committed before the first install:
# .npmrc
legacy-peer-deps=true
React version auto-detection crashes on ESLint 10.
The common pattern of
settings: { react: { version: "detect" } } calls a
legacy context.getFilename() API that ESLint 10 removed
outright. The whole lint run dies with
contextOrFilename.getFilename is not a function —
the same crash Next.js users hit independently
— the moment any rule needs the React version. We hardcode it
instead: react: { version: "19.2" }.
Neither of these is a mistake in the setup — they're open gaps in
how fast the ecosystem's peer ranges catch up to a new major ESLint
release. Writing them down as "this is a known gap, not a you
problem" is as important as the fix itself, because the next person
to hit ERESOLVE shouldn't assume they broke something.
How Do You Prove a Coding Standard Actually Works?
By running it against a real app end to end — including a deliberately bad commit — and confirming the pipeline turns red on bad code, not just green on clean code.
A standards doc that nobody has run end-to-end is a guess. So the
setup ships with demo-todo-app — a small Vite + React +
TypeScript to-do list that has every config file from the guide
applied: .nvmrc, .npmrc, the full
eslint.config.js, .prettierrc, Husky
hooks, a GitHub Actions CI workflow, and a PR template.
Proving the pipeline works isn't just "does
npm run validate pass on clean code" — that's necessary
but not sufficient. The real test is whether the gate
catches bad code:
echo "const x=1;console.log( x )" > src/bad.ts
git add src/bad.ts
git commit -m "test: verify pre-commit" # Prettier auto-fixes on commit
git revert --no-edit HEAD # clean up non-destructively
And separately, opening a PR containing
const y: any = 1 to confirm CI actually fails the build
on lint, not just warns. If either of those doesn't produce the
expected red, the setup is wrong — not the demo.
Where Are This Setup's Version Ceilings Today?
TypeScript stays below 6.1 until typescript-eslint's
peer range catches up, and React's deprecated
FormEvent type is replaced with
SyntheticEvent<HTMLFormElement> in every form
handler.
Two specific version ceilings are called out explicitly rather than left implicit:
-
typescript-eslint@8.64declares a peer range oftypescript: ">=4.8.4 <6.1.0". TypeScript 6.x — the native Go compiler — isn't supported by the lint stack yet. Migrating early as an individual would silently disable type-aware linting for everyone else on the team. -
@types/reactnow marksFormEvent/FormEventHandleras deprecated, whichno-deprecated(part ofstrictTypeChecked) flags on sight. Form handlers useSyntheticEvent<HTMLFormElement>instead now — a small thing, but the kind of thing that makes a standards doc feel current instead of copy-pasted from two years ago.
Both of these read like footnotes, but they're the actual value of a maintained standard over a one-time template: it tells you not just what to do, but where the edge of "safe to upgrade" is right now, and why.
A coding standard that isn't tested against a real app is a hypothesis. Ours ships with the proof attached — a working repo, a green CI run, and a documented case where we deliberately broke it to make sure it noticed.
How Do You Roll Coding Standards Out to a Team?
One person applies the setup on a branch, the team reviews the ruleset together once, then CI enforces it from that point on — no more case-by-case debate in every PR.
The rollout is intentionally boring: one person applies the guide on
a branch and opens chore: add coding standards. The
team reviews the rule set together once, and after that PR merges,
the rules are non-negotiable in review — no more case-by-case debate
over tab width or import order. Everyone runs
npm ci (which installs the hooks via the
prepare script) and switches to the pinned Node
version. Branch protection goes on main, the PR
template goes in, and from that point the standard enforces itself
instead of relying on someone remembering to check.
The lesson underneath all of it is the same one that shows up everywhere in software: a rule that lives only in a document gets ignored the first time it's inconvenient. A rule that lives in a CI gate — backed by a real app that proves the gate actually fires — doesn't need anyone to remember it at all.