Cursor Rules for TypeScript: The Complete Guide to AI-Assisted TypeScript Development

typescript dev.to

Cursor Rules for TypeScript: The Complete Guide to AI-Assisted TypeScript Development

TypeScript is the language where "it compiles" hides the longest lie. The checker green-lights parameters that are all any, catch clauses that access .message on unknown, and Promise.all over a loop that should have been serial. CI is green. Six months later a refactor reveals that half your "types" were structural lies and three of your DTOs drifted into four different shapes.

Then you add an AI assistant.

Cursor and Claude Code were trained on a planet's worth of TypeScript. Most of it predates strict mode, half of it predates satisfies, and a lot of it treats any as the default escape hatch. Ask for "a function that fetches a user," and you get a fetch with no abort signal, a catch (e: any), and a Promise<any> return. It runs. It's not the TypeScript you would ship.

The fix is .cursorrules — one file in the repo that tells the AI what idiomatic strict-mode TypeScript looks like. Seven rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.


How Cursor Rules Work for TypeScript Projects

Cursor reads rules from .cursorrules (single file at the root) or .cursor/rules/*.mdc (modular, recommended for monorepos). For TypeScript I use modular rules:

.cursor/
  rules/
    ts-core.mdc          # strict mode, any/unknown, interfaces
    ts-narrowing.mdc     # type guards, discriminated unions, never
    ts-generics.mdc      # constraints, variance, inference
    ts-errors.mdc        # Result types, custom Error classes
    ts-modules.mdc       # imports, exports, barrels
    ts-async.mdc         # Promise, AbortController, concurrency
Enter fullscreen mode Exit fullscreen mode

Frontmatter controls when each rule activates:

---
description: TypeScript core patterns for strict-mode projects
globs: ["**/*.ts", "**/*.tsx"]
alwaysApply: false
---
Enter fullscreen mode Exit fullscreen mode

Now the rules.


Rule 1: Strict Mode Is Non-Negotiable — No any, No Implicit Fallbacks

The most common TypeScript failure in AI-generated code is not missing types — it's fake types. Cursor reaches for any whenever inference gets hard and trusts that noUncheckedIndexedAccess is someone else's problem. tsc --strict catches it. A loose tsconfig.json does not.

The rule:

tsconfig has `strict`, `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `noImplicitOverride`. Code compiles
cleanly under all of those.

Never `any`. Use `unknown` and narrow with a type guard. `Record<string,
unknown>` is the escape hatch for free-form JSON.

No `as` to silence errors  only after a validator or DOM query with
a non-null postcondition. Prefer `satisfies` for "conforms to a type
without widening."

No `!` (non-null assertion). Narrow with `if (x == null) throw ...`.

Indexed access returns `T | undefined`  always handle undefined.
Enter fullscreen mode Exit fullscreen mode

Before — AI-generated code that compiles without strict:

function getUserEmail(users, id) {
  const user = users.find(u => u.id == id);
  return user.email!.toLowerCase();
}

async function loadConfig(path: string): Promise<any> {
  const raw = await fs.promises.readFile(path, "utf8");
  return JSON.parse(raw);
}
Enter fullscreen mode Exit fullscreen mode

users and id are implicit any. find returns T | undefined but user.email! cheats around it. The config is any — every downstream field access is untyped.

After — strict mode, narrowed, assertion at the boundary:

import { readFile } from "node:fs/promises";

interface User { id: string; email: string }
interface AppConfig { apiUrl: string; retries: number }

function getUserEmail(users: readonly User[], id: string): string {
  const user = users.find((u) => u.id === id);
  if (user === undefined) throw new Error(`User not found: ${id}`);
  return user.email.toLowerCase();
}

function assertAppConfig(value: unknown): AppConfig {
  if (
    typeof value !== "object" || value === null ||
    typeof (value as AppConfig).apiUrl !== "string" ||
    typeof (value as AppConfig).retries !== "number"
  ) {
    throw new TypeError("config.json does not match AppConfig");
  }
  return value as AppConfig;
}

async function loadConfig(path: string): Promise<AppConfig> {
  return assertAppConfig(JSON.parse(await readFile(path, "utf8")));
}
Enter fullscreen mode Exit fullscreen mode

No any. No !. assertAppConfig is the one place the assertion is safe — and it's auditable.


Rule 2: Interface-First Design — Shape Before Behavior

AI generates inline object types in function signatures. The same structure appears in three places as three anonymous types that slowly drift apart — one has createdAt: Date, one has createdAt: string, one has created_at: number, and the compiler can't see the disagreement.

The rule:

Named `interface` or `type` for every shape that crosses a boundary
(function params, returns, API bodies, component props, store state).
Never inline object types in function signatures.

`interface` for extendable object shapes; `type` for unions,
intersections, tuples, mapped, conditional.

One interface per concept. Derive with Pick/Omit/Partial/Required/
Readonly  no hand-copied fields. `Readonly<T>` is the default for
non-mutated params.

Types exported explicitly  no `export *`.
Enter fullscreen mode Exit fullscreen mode

Before — inline shapes, drifting duplicates:

async function createOrder(
  items: { productId: string; quantity: number; price: number }[],
  customer: { id: string; email: string; tier: string }
): Promise<{ orderId: string; total: number; status: string }> {
  // ...
}

async function sendConfirmation(
  order: { orderId: string; total: number },
  customer: { email: string }
): Promise<void> {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

customer.tier is string — any typo compiles. The order shape in sendConfirmation is a narrower duplicate; rename a field and the compiler has no idea they were related.

After — named interfaces, literal unions, derived types:

export type CustomerTier = "free" | "pro" | "enterprise";
export type OrderStatus = "pending" | "confirmed" | "shipped";

export interface OrderItem { productId: string; quantity: number; price: number }
export interface Customer { id: string; email: string; tier: CustomerTier }
export interface Order {
  orderId: string;
  total: number;
  status: OrderStatus;
  items: readonly OrderItem[];
  customer: Customer;
}

export type OrderSummary = Pick<Order, "orderId" | "total">;
export type CustomerContact = Pick<Customer, "email">;

export async function createOrder(
  items: readonly OrderItem[],
  customer: Customer
): Promise<Order> { /* ... */ }

export async function sendConfirmation(
  order: Readonly<OrderSummary>,
  customer: Readonly<CustomerContact>
): Promise<void> { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

One source of truth. Literal unions reject typos. OrderSummary and CustomerContact derive from the originals — rename a field and every derived type follows.


Rule 3: Type Narrowing — Guards, Discriminated Unions, and Exhaustive never

AI loves isLoading?: boolean; error?: string; data?: T — three independent optional fields that together represent "loading and errored with data simultaneously," nonsense your UI then has to defend against. Discriminated unions make illegal states unrepresentable; never in the default branch of a switch makes adding a state a compile error at every call site.

The rule:

Multi-state values are discriminated unions with a single literal tag
(`status`, `kind`, `type`). Each branch carries only the data it needs.

No parallel boolean/optional fields for mutually exclusive states.

Handle every variant in a `switch` on the tag. Default case binds
`const _: never = value` so new variants break all switch sites.

User-defined guards use `x is T`. Prefer narrow-in-place over casts.

Parse external input once at the boundary (Zod/Valibot/assertion);
flow typed data through. No `as` chains in business logic.
Enter fullscreen mode Exit fullscreen mode

Before — boolean soup that permits impossible states:

interface RequestState<T> {
  isLoading: boolean;
  isError: boolean;
  error?: string;
  data?: T;
}

function render(state: RequestState<User>) {
  if (state.isLoading) return "Loading…";
  if (state.isError) return `Error: ${state.error ?? "unknown"}`;
  return `Hello, ${state.data!.name}`;
}
Enter fullscreen mode Exit fullscreen mode

Four independent fields, sixteen possible states, only four of which are legal. state.data! is the AI admitting it has no idea whether data exists.

After — discriminated union, exhaustive switch, no !:

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: T };

function render(state: RequestState<User>): string {
  switch (state.status) {
    case "idle":
      return "";
    case "loading":
      return "Loading…";
    case "error":
      return `Error: ${state.error.message}`;
    case "success":
      return `Hello, ${state.data.name}`;
    default: {
      const _exhaustive: never = state;
      return _exhaustive;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Every branch has exactly the data it needs. Add { status: "cancelled" } and the compiler errors on _exhaustive in every render until you handle it. No non-null assertions, no defensive checks, no imaginary states.


Rule 4: Constrained Generics — Never Bare <T>

AI generates <T> with no constraints and a body that assumes T is an object. Without constraints, generics don't help — they obscure where the wrongness is. extends turns a generic into a type-safe contract.

The rule:

Every generic parameter has an `extends` constraint.

Common shapes: `<T extends object>`, `<T extends Record<string,
unknown>>`, `<K extends keyof T>`, `<T extends readonly unknown[]>`,
`<T extends (...args: never[]) => unknown>`.

Prefer inference over annotation at call sites. Passing `<T>` args
explicitly signals a bad signature.

Generics are not obfuscated `any`. `identity<T>(x: T): T` is fine;
`save<T>(x: T) { db.insert(x as any) }` is not.
Enter fullscreen mode Exit fullscreen mode

Before — unconstrained generic, no real safety:

function merge<T, U>(a: T, b: U): T & U {
  return { ...a, ...b };
}

function pick<T>(obj: T, key: string): unknown {
  return (obj as Record<string, unknown>)[key];
}

merge(1, "two"); // compiles. Produces { } at runtime.
Enter fullscreen mode Exit fullscreen mode

T and U can be anything. pick's return is unknown — no caller benefits from the fact that obj has a known shape.

After — constrained generics, exact return types:

function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

function pick<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Ada", age: 36 };
const name = pick(user, "name"); // type: string
const age = pick(user, "age"); // type: number
pick(user, "nope"); // compile error — "nope" is not keyof
Enter fullscreen mode Exit fullscreen mode

merge(1, "two") no longer compiles. pick returns the exact value type for the exact key. Callers get autocompletion on the key argument.


Rule 5: Result Types for Recoverable Errors — try/catch at the Edge Only

AI-generated error handling is a black hole for type information. Every catch (e) is unknown; every response is console.log(e.message), which throws if e is a string. Exceptions pass silently through twenty layers of types — no signature mentions them.

The fix is Result<T, E>. Exceptions stay for truly exceptional things. Expected failures — validation, network, authorization — become values the type system can see.

The rule:

Recoverable failures return Result:
  type Result<T, E = Error> =
    | { ok: true; value: T } | { ok: false; error: E };

Domain errors extend Error with `readonly name` and typed context
fields (statusCode, endpoint, cause).

Wrap infrastructure at the boundary; domain code traffics in Result
values. At the top of the call stack, Result becomes an HTTP
response or exit code.

`catch (e: unknown)` only in wrappers. Narrow with `instanceof`
before accessing fields. Wrap rethrows with `new MyError("msg", { cause: e })`.

null = "legitimately absent." Result = "something went wrong." Don't
mix.
Enter fullscreen mode Exit fullscreen mode

Before — untyped catch, silent failure, null-as-error:

async function fetchUser(id: string) {
  try {
    const res = await fetch(`/api/users/${id}`);
    return await res.json();
  } catch (e) {
    console.log(e.message);
    return null;
  }
}

const user = await fetchUser("42");
if (user) render(user);
Enter fullscreen mode Exit fullscreen mode

The caller has no idea whether null means "not found," "network dead," or "server returned HTML." e.message throws if e is a string. The response is any.

After — Result, named errors, cause chain preserved:

export class NotFoundError extends Error {
  readonly name = "NotFoundError";
  constructor(public readonly resource: string, public readonly id: string) {
    super(`${resource} not found: ${id}`);
  }
}

export class NetworkError extends Error {
  readonly name = "NetworkError";
  constructor(message: string, options?: { cause?: unknown }) { super(message, options); }
}

export type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

export async function fetchUser(
  id: string
): Promise<Result<User, NotFoundError | NetworkError>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (res.status === 404) return { ok: false, error: new NotFoundError("User", id) };
    if (!res.ok) return { ok: false, error: new NetworkError(`HTTP ${res.status}`) };
    return { ok: true, value: (await res.json()) as User };
  } catch (e: unknown) {
    return { ok: false, error: new NetworkError("fetch failed", { cause: e }) };
  }
}

// Caller is forced to handle every branch.
const result = await fetchUser("42");
if (!result.ok) {
  if (result.error instanceof NotFoundError) return renderNotFound();
  return renderOutage(result.error);
}
renderUser(result.value);
Enter fullscreen mode Exit fullscreen mode

The signature tells you everything the function can return. Nothing gets swallowed. The cause chain survives.


Rule 6: Module Organization — Explicit Exports, No Barrels, Ordered Imports

AI re-exports everything from index.ts, mixes import type with value imports, and writes import * as everything from "./" which defeats tree-shaking. In a project of any size this becomes a dependency soup — circular imports, refactors that touch a hundred files because every index.ts re-exports the world.

The rule:

Named exports only  no default exports.

No barrel re-exports for internal modules. Barrels are fine only at
package boundaries. Deep imports tree-shake and make ownership obvious.

`import type { ... }` for type-only imports; turn on
`verbatimModuleSyntax`.

Import order (blank line between groups): node built-ins, external
packages, internal absolute (`@/...`), relative (`./...`).

No circular imports. Extract shared types to a third module if two
modules need each other.
Enter fullscreen mode Exit fullscreen mode

Before — default export, barrel leak, mixed imports:

// src/user/index.ts
export * from "./service";
export * from "./types";
export * from "./routes";
export { default } from "./service";

// src/order/service.ts
import User from "../user";
import * as everything from "../user";

export default class OrderService {
  constructor(private readonly userService: typeof User) {}
}
Enter fullscreen mode Exit fullscreen mode

Pulling in user drags the whole barrel — routes, types, service — even if the consumer needed one type. Default exports make the user unclear at the import site. import * as everything defeats tree-shaking.

After — named exports, direct imports, ordered groups, type-only where possible:

// src/user/service.ts
import { randomUUID } from "node:crypto";

import { logger } from "@/lib/logger";

import type { User } from "./types";

export class UserService {
  async findById(id: string): Promise<User | null> {
    logger.info("user.lookup", { id, traceId: randomUUID() });
    // ...
    return null;
  }
}

// src/order/service.ts
import type { UserService } from "@/user/service";
import type { Order, OrderItem } from "./types";

export class OrderService {
  constructor(private readonly users: UserService) {}

  async create(userId: string, items: readonly OrderItem[]): Promise<Order> {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

UserService is imported as a type — zero runtime cost. Every dependency is explicit and direct. Refactors touch the files that actually use a name.


Rule 7: Async Patterns — Promise.all for Concurrency, AbortController for Cancellation

AI writes for (const x of xs) { await fetch(x); } because that's what most tutorials show. Correct, and ten times slower than it needs to be when the operations are independent. It also writes fetch() without an AbortController, so a dropped client leaves your server holding open upstream calls.

The rule:

Independent awaits run in parallel with `Promise.all` /
`allSettled`. No `for`/`await` unless ordering truly matters.

Bound fan-out concurrency with `p-limit` or a small batcher.

Every outbound call takes an `AbortSignal`. HTTP handlers forward
the incoming request's signal to downstream calls.

`Promise.all` when any failure should cancel the rest;
`Promise.allSettled` when failures must not cancel siblings.

Don't mix `.then()` chains with `async/await` in one function.

Fire-and-forget: `void fireAndForget()` so linters know you meant it.
Enter fullscreen mode Exit fullscreen mode

Before — serial loop, no cancellation, then/await salad:

async function getAllPrices(symbols: string[]) {
  const prices: Record<string, number> = {};
  for (const s of symbols) {
    const res = await fetch(`/api/price/${s}`);
    prices[s] = (await res.json()).price;
  }
  return prices;
}

function subscribe(userId: string) {
  fetch(`/api/users/${userId}`).then((r) =>
    r.json().then((u) => console.log(u))
  );
}
Enter fullscreen mode Exit fullscreen mode

Serial: 100 symbols = 100 sequential round trips. No abort. Mixed paradigms.

After — bounded parallel, abortable, one style:

async function getAllPrices(
  symbols: readonly string[],
  signal: AbortSignal
): Promise<Record<string, number>> {
  const results = await Promise.all(
    symbols.map(async (s) => {
      const res = await fetch(`/api/price/${s}`, { signal });
      const body = (await res.json()) as { price: number };
      return [s, body.price] as const;
    })
  );
  return Object.fromEntries(results);
}

async function subscribe(userId: string, signal: AbortSignal): Promise<User> {
  const res = await fetch(`/api/users/${userId}`, { signal });
  return (await res.json()) as User;
}
Enter fullscreen mode Exit fullscreen mode

Every network call propagates the caller's AbortSignal. All 100 prices go out together. One style end to end.


The Complete .cursorrules File

Drop this in the repo root. Cursor and Claude Code both pick it up.

# TypeScript — Production Patterns

## Strict Mode
- tsconfig: strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes,
  noImplicitOverride.
- No `any`. Use `unknown` + narrow. No `!`. No `as` except after
  validator. Prefer `satisfies`.
- Indexed access returns `T | undefined` — always check.

## Interfaces First
- Named interface/type for every shape crossing a boundary. No inline
  object types in signatures.
- `interface` for extendable shapes; `type` for unions/intersections.
- Derive with Pick/Omit/Partial/Required/Readonly. `Readonly<T>` default
  for non-mutated params. Literal unions over `string`.

## Narrowing
- Multi-state values = discriminated unions with one tag.
- `switch` on the tag; default binds `_: never`.
- User-defined guards use `x is T`.
- Parse external input at the boundary (Zod/Valibot/assertion).

## Generics
- Every generic has `extends`. Prefer inference at call sites.
- `<K extends keyof T>` for property accessors.
- Generics are not obfuscated `any`.

## Errors
- `Result<T, E> = { ok: true; value: T } | { ok: false; error: E }`.
- Domain errors extend Error, set `readonly name`, typed context.
- Wrap infra at edges. `catch (e: unknown)` only in wrappers. Preserve `cause`.
- null = "absent"; Result = "failed."

## Modules
- Named exports only. No default exports. No internal barrels.
- `import type` + `verbatimModuleSyntax`.
- Import order: node, externals, internal absolute, relative.
- No circular imports.

## Async
- `Promise.all` / `allSettled` for independent awaits. No serial
  for/await unless ordering matters.
- Bound large fan-out. Every outbound call takes `AbortSignal`.
- Don't mix `.then()` with `async/await`. `void` fire-and-forget.
Enter fullscreen mode Exit fullscreen mode

Two End-to-End Examples

Example 1: "Load a config file and fetch the current user from it."

Without rules:

async function getUser(configPath: string) {
  const raw = require("fs").readFileSync(configPath, "utf8");
  const config = JSON.parse(raw);
  try {
    const res = await fetch(config.apiUrl + "/me");
    return await res.json();
  } catch (e) {
    console.log(e.message);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Sync file read in an async function. config is any. No abort. Null for "everything that went wrong."

With rules:

import { readFile } from "node:fs/promises";

interface AppConfig { apiUrl: string }
interface User { id: string; email: string }

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

class ApiError extends Error {
  readonly name = "ApiError";
  constructor(public readonly status: number, message: string) { super(message); }
}

async function loadConfig(path: string): Promise<AppConfig> {
  const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
  if (
    typeof parsed !== "object" || parsed === null ||
    typeof (parsed as AppConfig).apiUrl !== "string"
  ) {
    throw new TypeError(`Invalid config at ${path}`);
  }
  return parsed as AppConfig;
}

export async function getUser(
  configPath: string,
  signal: AbortSignal
): Promise<Result<User, ApiError>> {
  const config = await loadConfig(configPath);
  const res = await fetch(`${config.apiUrl}/me`, { signal });
  if (!res.ok) {
    return { ok: false, error: new ApiError(res.status, `GET /me failed`) };
  }
  return { ok: true, value: (await res.json()) as User };
}
Enter fullscreen mode Exit fullscreen mode

Typed config. Typed response. Abort support. Result type. One obvious place the unknown parse is asserted.

Example 2: "Reconcile a batch of orders against a pricing service concurrently."

Without rules:

async function reconcile(orders) {
  const results = [];
  for (const o of orders) {
    const res = await fetch("/api/price/" + o.sku);
    results.push({ ok: o.price == res.json().price, id: o.id });
  }
  return results;
}
Enter fullscreen mode Exit fullscreen mode

Implicit any. Serial. Loose equality. Untyped everywhere.

With rules:

interface Order {
  readonly id: string;
  readonly sku: string;
  readonly price: number;
}

interface Reconciliation {
  readonly id: string;
  readonly ok: boolean;
}

export async function reconcile(
  orders: readonly Order[],
  signal: AbortSignal
): Promise<readonly Reconciliation[]> {
  return Promise.all(
    orders.map(async (o) => {
      const res = await fetch(`/api/price/${o.sku}`, { signal });
      const body = (await res.json()) as { price: number };
      return { id: o.id, ok: o.price === body.price } satisfies Reconciliation;
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

Parallel. Abortable. Typed end-to-end. satisfies confirms the object literal conforms without widening.


Get the Full Pack

These seven rules cover the patterns where AI consistently reaches for the wrong TypeScript idiom. Drop them into .cursorrules and your next prompt will produce typed, strict-mode, async-correct code — no re-prompting round-trip.

If you want the expanded pack — these seven plus rules for React components, Next.js App Router, Prisma/Drizzle, tRPC, Vitest, Zod, and the Node-specific rules I use on Fastify/Hono services — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship TypeScript you would actually merge.

Source: dev.to

arrow_back Back to Tutorials