md` configuration at the repository root, which the AI reads before every generation task. The rules enforce compiler hardening, boundary validation, deterministic state modeling, and explicit async control flow.
1. Compiler Hardening: strict: true and friends
The compiler must act as the first reviewer. Standard strict: true is baseline; the following flags close AI-specific blind spots:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
"verbatimModuleSyntax": true
}
}
noUncheckedIndexedAccess is the single biggest correctness win after strict: arr[0] becomes T | undefined instead of T, forcing you to handle the empty-array case at the call site instead of three layers down with a Cannot read properties of undefined. AI hates this flag because half its training data assumes index access is total. That's the point β every arr[i] either gets a real check or a real default, and the bugs surface at compile time.
2. Boundary Validation: any is banned. unknown is the escape hatch
any disables the type checker for that value and everything it touches. One any in a hot path silently propagates through five files. unknown keeps the value opaque until you prove what it is, which is what you actually wanted in the first place.
// Banned
function parseConfig(raw: any) {
return raw.server.port + 1; // crashes at runtime if raw is null
}
// Required
function parseConfig(raw: unknown): number {
if (
typeof raw === "object" && raw !== null &&
"server" in raw && typeof raw.server === "object" && raw.server !== null,
"port" in raw.server && typeof raw.server.port === "number"
) {
return raw.server.port + 1;
}
throw new TypeError("invalid config shape");
}
In real code you parse with Zod/Valibot/ArkType β the principle is the same: unknown at the boundary, narrow before use, no any ever. Not even in tests. Not even "just for now". ESLint @typescript-eslint/no-explicit-any set to error, no escape hatch.
3. State Modeling: Discriminated unions & exhaustiveness checks
When a value can be in one of N shapes, model it as a discriminated union with a literal kind/status/type field. AI defaults to a single object with optional fields and booleans, which is how you get the classic React bug where loading is true and data is also defined.
type Request =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };
function render(r: Request): string {
switch (r.status) {
case "idle": return "β";
case "loading": return "Loadingβ¦";
case "success": return r.data.email; // narrowed automatically
case "error": return r.error.message;
default: return assertNever(r);
}
}
function assertNever(x: never): never {
throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
}
Add a { status: "retrying" } variant later and the default line stops compiling because r is no longer never. The compiler immediately points at every switch that needs updating. You can't ship the bug. This single pattern eliminates an entire category of bugs that runtime tests almost never catch.
4. Async Control Flow: No floating promises
A bare promise call (doThing() instead of await doThing()) silently runs in the background and swallows rejections. AI does this constantly β especially in event handlers, where the function signature is synchronous and the model "fixes" the type error by dropping the await.
// Banned β unhandled rejection, race with caller
function onClick() {
saveDraft();
}
// Fixed β explicit await
async function onClick() {
await saveDraft();
}
// Fire-and-forget on purpose? Be explicit and route errors somewhere.
function onClick() {
void saveDraft().catch(reportError);
}
ESLint rule @typescript-eslint/no-floating-promises set to error. Same rule for promise arrays: Promise.all(items.map(save)) β never items.forEach(async i => save(i)), which silently loses every error and runs in the wrong order anyway. If the API is genuinely fire-and-forget, document it and route failures to your error reporter explicitly. void promise makes the intent visible in code review β promise; does not.
Architecture Decision: The remaining 12 rules enforce ESM-only imports, branded primitives for semantic types, utility type derivation (Pick, Omit, ReturnType), readonly defaults, and type-level testing (expect-type/tsd). Together, they create a closed-loop type system where AI generation is constrained by compiler guarantees rather than heuristic guesswork.
Pitfall Guide
- Silent
any Propagation: Using any disables type checking for the entire dependency chain. One any in a utility function can corrupt types across five downstream modules. Enforce @typescript-eslint/no-explicit-any at the error level with zero exceptions.
- Unbounded Index Access: Assuming
arr[i] returns T ignores the reality of sparse arrays and empty collections. Enable noUncheckedIndexedAccess to force explicit undefined handling at every access site.
- Optional-Flag State Machines: Modeling UI/data states with booleans (
isLoading, hasError) creates invalid permutations (e.g., loading and data both true). Use discriminated unions with a literal discriminant field to make invalid states unrepresentable.
- Floating Promises: Dropping
await or explicit .catch() in event handlers causes swallowed rejections and unpredictable race conditions. Always await, return, or explicitly void the promise with error routing.
- Unconstrained Generics: Writing
<T> without constraints is functionally identical to any. Always constrain generics (<T extends BaseShape>) to preserve type safety and enable proper inference.
- Type Assertions at Boundaries: Using
as Foo on external data (API responses, localStorage, CLI args) bypasses validation and shifts errors to runtime. Replace assertions with schema validation (Zod/Valibot) and explicit narrowing.
- Duplicated Type Sources: Manually mirroring upstream API types leads to immediate drift when contracts change. Generate types from OpenAPI/Swagger or derive them using
Awaited, ReturnType, and Parameters to maintain a single source of truth.
Deliverables
- Blueprint:
CLAUDE.md Rules Pack (16 rules) β A ready-to-drop configuration file that hardens TypeScript compilation, enforces boundary validation, mandates exhaustiveness checks, and locks async control flow. Covers strict flags, unknown boundaries, discriminated unions, branded primitives, ESM enforcement, and type-level testing.
- Checklist: Pre-commit Type Safety Validation
- Configuration Templates:
tsconfig.json hardening snippet (strict + safety flags)
- ESLint
.eslintrc ruleset for @typescript-eslint strict mode
- Zod/Valibot boundary validation boilerplate for API responses
assertNever exhaustiveness helper for discriminated unions
Free sample (this article + Gist): CLAUDE.md for TypeScript on GitHub Gist
Get the complete CLAUDE.md Rules Pack β 35+ stacks, ready-to-drop files for every project: