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
Frontmatter controls when each rule activates:
---
description: TypeScript core patterns for strict-mode projects
globs: ["**/*.ts", "**/*.tsx"]
alwaysApply: false
---
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.
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);
}
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")));
}
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 *`.
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> {
// ...
}
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> { /* ... */ }
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.
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}`;
}
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;
}
}
}
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.
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.
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
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.
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);
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);
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.
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) {}
}
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> {
// ...
}
}
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.
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))
);
}
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;
}
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.
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;
}
}
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 };
}
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;
}
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;
})
);
}
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.