DEV Community

Olivia Craft
Olivia Craft

Posted on

16 CLAUDE.md Rules That Make AI Write Truly Type-Safe TypeScript

If you've ever asked Claude Code, Cursor, or Copilot to "add a function" to a TypeScript project, you've watched it happen in real time: the function takes any, returns any, asserts a network response with as User, swallows a promise it never awaits, and the type checker — the one tool you turned on for exactly this reason — shrugs and lets it through.

That's not because TypeScript is broken. It's because the model's training data is half a decade of Stack Overflow answers from the era when any was the answer, when // @ts-ignore was a pragma instead of a smell, and when "type assertion" meant "tell the compiler to shut up".

Drop a CLAUDE.md at the root of your repo and the AI reads it before every task. Here are the four rules that fix the worst patterns. The full pack has sixteen.


Rule 1: strict: true and friends — the compiler is your first reviewer

strict: true is table stakes. It's the flags strict doesn't include where the AI runs wild.

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "useUnknownInCatchVariables": true,
    "verbatimModuleSyntax": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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.


Rule 2: any is banned. unknown is the escape hatch — and it must be narrowed before use

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");
}
Enter fullscreen mode Exit fullscreen mode

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.


Rule 3: Discriminated unions for state, exhaustiveness checks via never

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)}`);
}
Enter fullscreen mode Exit fullscreen mode

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.


Rule 4: No floating promises — await it, return it, or void it explicitly

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);
}
Enter fullscreen mode Exit fullscreen mode

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.


The other twelve rules

The full Gist also covers:

  • Rule 3: Ban type assertions (as Foo) except at trusted boundaries
  • Rule 4: Validate at every boundary — unknown in, parsed types out (Zod/Valibot)
  • Rule 7: readonly by default — properties, arrays, tuples
  • Rule 8: Generics constrain — unconstrained <T> is any in disguise
  • Rule 9: Use the utility types — Pick, Omit, Partial, Awaited, ReturnType
  • Rule 10: interface for object shapes, type for unions and intersections
  • Rule 11: Async returns Promise<T>, errors typed where it matters, unknown in catch
  • Rule 13: ESM-only — import/export, import type for types, no require
  • Rule 14: Brand primitives that mean different things (UserId, Emailstring)
  • Rule 15: Type-level tests with expect-type or tsd and @ts-expect-error
  • Rule 16: One source of truth per type — generate or derive, never duplicate

Wrapping up

These rules don't replace the TypeScript handbook or the @typescript-eslint docs. They encode the failure modes AI repeats most often when generating TypeScript: any-by-default, hope-driven type assertions, optional-flag state machines, swallowed promises, hand-typed mirrors of upstream APIs that drift the moment someone deploys a new version.

Strict-mode flags, banning any, discriminated unions with exhaustiveness, branded primitives, schema-validated boundaries, and no floating promises aren't style preferences. They're the difference between a TypeScript project that catches bugs at compile time — like it's supposed to — and one that ships JavaScript with type annotations on top.

Drop the file at the root of your repo. The next AI prompt produces TypeScript your future self won't have to rewrite at 3am.


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:

Top comments (0)