- Published on
> Clean Code Is the Best Documentation — Especially for AI Agents
- Authors
- Name
- Fred Pope
- @fred_pope
The thesis (recap)
Traditional docs explain code from the outside; clean code explains itself from the inside:
- Names express intent
- Types encode contracts & invariants
- Small functions isolate behavior
- Tests read like examples
- Comments explain why, not what
For AI agents, this means:
- Smaller, denser context windows (less room to guess)
- Higher retrieval precision (clear, consistent identifiers)
- Fewer hallucinations (less ambiguity)
- Tests as guardrails (source of behavioral truth)
You still keep a thin layer of external docs: Runbook, Glossary, ADRs, and generated API docs. Everything else lives in the source.
Updated quick checklist — and what enforces it
Print this and stick it in your PR template.
- Names reveal intent Enforced by:
unicorn/prevent-abbreviations
,@typescript-eslint/naming-convention
- Types/guards encode invariants Enforced by:
tsconfig
strict,@typescript-eslint/no-explicit-any
,@typescript-eslint/strict-boolean-expressions
- Small, single-purpose functions Enforced by:
max-params
,max-lines-per-function
,complexity
,sonarjs/cognitive-complexity
- Tests read like examples (and exist) Enforced by: Vitest + a tiny "ensure test siblings" script
- Comments explain why (not what) Enforced by:
jsdoc/require-jsdoc
,jsdoc/require-description
(exported APIs) - Predictable imports & structure Enforced by:
simple-import-sort
,import/no-default-export
(Next.js overrides where required) - CI blocks merges if anything fails Enforced by: GitHub Actions +
tsc --noEmit
The linter that encodes the checklist (Next.js + TypeScript + tRPC + Drizzle)
tsconfig.json
— make types the contract
1) {
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"types": ["vitest/globals"]
},
"include": ["src", "next-env.d.ts"]
}
.eslintrc.cjs
— encode intent, limit complexity, require "why"
2) /* eslint-env node */
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: [
"@typescript-eslint",
"import",
"unicorn",
"sonarjs",
"jsdoc",
"simple-import-sort",
"vitest"
],
extends: [
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:unicorn/recommended",
"plugin:sonarjs/recommended",
"plugin:jsdoc/recommended",
"plugin:vitest/recommended"
],
settings: {
"import/resolver": { typescript: true }
},
rules: {
/* Intent in names */
"unicorn/prevent-abbreviations": ["error", {
allowList: {
args: true, env: true, db: true, api: true, id: true, prop: true, params: true, ctx: true,
req: true, res: true
}
}],
"@typescript-eslint/naming-convention": [
"error",
{ "selector": "typeLike", "format": ["PascalCase"] },
{ "selector": "variable", "types": ["boolean"], "format": ["camelCase"], "prefix": ["is","has","can","should"] }
],
"import/no-default-export": "error",
/* Types/guards encode invariants */
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/strict-boolean-expressions": ["error", { allowNullableBoolean: false }],
"@typescript-eslint/no-non-null-assertion": "error",
/* Small, single-purpose functions */
"max-params": ["error", 3],
"max-lines-per-function": ["error", { max: 60, skipComments: true, skipBlankLines: true }],
"complexity": ["error", 10],
"sonarjs/cognitive-complexity": ["error", 15],
/* Comments explain WHY (not WHAT) on exported APIs */
"jsdoc/require-jsdoc": ["error", {
publicOnly: { esm: true },
require: { FunctionDeclaration: true, MethodDefinition: true, ClassDeclaration: true },
contexts: [
"ExportNamedDeclaration > FunctionDeclaration",
"TSTypeAliasDeclaration",
"TSInterfaceDeclaration"
]
}],
"jsdoc/require-description": "error",
/* Predictable imports/structure */
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"import/order": "off",
/* File naming: Components or utilities can be kebab- or Pascal-case.
Next special files are exempted below. */
"unicorn/filename-case": ["error", {
cases: { kebabCase: true, pascalCase: true },
ignore: ["^page$", "^layout$", "^error$", "^loading$", "^not-found$"]
}],
/* Pragmatic defaults */
"unicorn/no-null": "off" // allow `null` to model absence
},
overrides: [
/* Next.js requires default exports for pages/app special files */
{
files: [
"src/pages/**/*.{ts,tsx}",
"src/app/**/page.tsx",
"src/app/**/layout.tsx",
"src/app/**/error.tsx",
"src/app/**/loading.tsx",
"src/app/**/not-found.tsx",
"next.config.*"
],
rules: { "import/no-default-export": "off" }
},
/* Test files: relax function-size for describe/it blocks */
{
files: ["**/*.test.{ts,tsx}"],
rules: {
"max-lines-per-function": ["error", { max: 120, skipComments: true, skipBlankLines: true }]
}
}
]
};
3) Minimal "tests must exist" check (fast & simple)
Ensures every non-test module has a sibling
*.test.ts(x)
.
scripts/ensure-test-siblings.mjs
import { globby } from "globby";
import { relative } from "node:path";
const files = await globby(["src/**/*.ts", "src/**/*.tsx", "!src/**/*.test.*", "!src/**/_*.ts"]);
const missing = [];
for (const f of files) {
const guesses = [f.replace(/\.tsx?$/, ".test.ts"), f.replace(/\.tsx?$/, ".test.tsx")];
const found = await globby(guesses);
if (found.length === 0) missing.push(relative(process.cwd(), f));
}
if (missing.length) {
console.error("❌ Missing tests for:");
for (const m of missing) console.error(" -", m);
process.exit(1);
} else {
console.log("✅ Tests exist for all modules.");
}
package.json
scripts
4) {
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"verify:tests-exist": "node scripts/ensure-test-siblings.mjs",
"test": "vitest run",
"validate": "pnpm typecheck && pnpm lint && pnpm verify:tests-exist && pnpm test"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
"eslint": "^9",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsdoc": "^48",
"eslint-plugin-simple-import-sort": "^12",
"eslint-plugin-sonarjs": "^0.25",
"eslint-plugin-unicorn": "^55",
"eslint-plugin-vitest": "^0.4",
"globby": "^14",
"typescript": "^5",
"vitest": "^2"
}
}
5) CI that blocks merges if anything slips
.github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm validate
Here's an example in action
Before (vague, complex, under-tested)
// src/billing/calc.ts
export function calc(a: number, b: number, c: number, d: boolean, e?: any) {
let r = Math.round((a / b) * c);
if (d && e) r += e;
return r;
}
// (no test file)
What the tooling flags:
unicorn/prevent-abbreviations
:a
,b
,c
,d
,e
convey no intent@typescript-eslint/no-explicit-any
: forbidsany
max-params
: 5 > 3jsdoc/require-jsdoc
: exported API lacks a description of whyverify:tests-exist
: nocalc.test.ts(x)
After (the code is the documentation)
// src/billing/proration.ts
/**
* Prorates a cycle charge by remaining days.
* Floors daily rate to avoid over-crediting in variable-length months (28–31 days).
*/
export function prorateSubscription(
amountCents: number,
cycleDays: number,
remainingDays: number
): number {
if (cycleDays <= 0) throw new Error("cycleDays must be > 0");
const safeRemaining = Math.min(Math.max(remainingDays, 0), cycleDays);
const daily = Math.floor(amountCents / cycleDays);
return daily * safeRemaining;
}
// src/billing/proration.test.ts
import { expect, it, describe } from "vitest";
import { prorateSubscription } from "./proration";
describe("prorateSubscription", () => {
it("prorates by remaining days", () => {
expect(prorateSubscription(3000, 30, 10)).toBe(1000);
});
it("never credits more than charged", () => {
expect(prorateSubscription(3000, 30, 40)).toBe(3000);
});
it("handles zero or negative remaining days", () => {
expect(prorateSubscription(3000, 30, -2)).toBe(0);
});
});
Result: names carry intent, invariants are guarded, function is small and linear, exported API explains why, and tests read like examples. An agent can infer behavior from a tiny, high-signal context—no extra docs needed.
Why this reduces hallucinations (context-budget intuition)
A "docs-first" change often forces an agent to ingest wiki pages + PR threads + diffs (~5–7k tokens). The "code-as-doc" version is small functions + JSDoc + tests (~700–1,400 tokens). Less ambiguity in fewer tokens reduces the need for the model to guess.
Adoption plan (tighten gradually)
- Start gentle: keep the rules but loosen thresholds (e.g.,
max-lines-per-function: 100
,complexity: 15
). Land the linter. - Gate in CI: require
pnpm validate
to pass on PRs. Add the test-sibling check. - Tighten over time: nudge complexity/size thresholds downward as code gets cleaner.
- Keep docs thin: maintain Runbook, Glossary, and ADRs; migrate everything else into code, types, and tests.
With this setup, your intent is literally encoded in your code. The linter + types + tests turn your repo into machine- and human-readable documentation.
Copy/paste summary
- Checklist (printable): in this post
- Configs:
tsconfig.json
,.eslintrc.cjs
,package.json
scripts - Script:
scripts/ensure-test-siblings.mjs
- CI:
.github/workflows/ci.yml
- Example:
proration.ts
+proration.test.ts
If you want a repo-ready PR that drops these files into a Next.js/TypeScript project (with thresholds tuned to your codebase), say the word and I'll generate it.