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 cinpm 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 civalidatebuild 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 functionthe 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.64 declares a peer range of typescript: ">=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/react now marks FormEvent / FormEventHandler as deprecated, which no-deprecated (part of strictTypeChecked) flags on sight. Form handlers use SyntheticEvent<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.