Cursor Rules for TypeScript: 6 Rules That Make AI Write Type-Safe TypeScript
Cursor Rules for TypeScript: 6 Rules That Make AI Write Type-Safe TypeScript Cursor generates TypeScript fast. The problem? It reaches for any the moment things get complex, returns bare objects instead of typed responses, skips discriminated unions, and writes error handling that throws away type information. The fix isn't better prompting. It's better rules. Here are 6 cursor rules for TypeScript that eliminate the most common AI coding mistakes. Each one includes a bad vs. good example so you can see exactly what changes. any — Use unknown and Type Guards Instead Without this rule, AI assistants default to any whenever the type isn't immediately obvious. Once any enters your codebase, TypeScript's entire type system stops helping you. The rule: Never use the `any` type. Use `unknown` when the type is genuinely not known, then narrow with type guards before accessing properties. Use `Record` instead of `Record` for arbitrary objects. Bad — any everywhere, no safety: function parseApiResponse(data: any): any { return { id: data.id, name: data.name, metadata: data.metadata, }; } function handleEvent(event: any) { console.log(event.target.value); // no type checking at all } Good — unknown with narrowing: interface ApiUser { id: string; name: string; metadata: Record; } function isApiUser(data: unknown): data is ApiUser { return ( typeof data === "object" && data !== null && "id" in data && "name" in data && typeof (data as ApiUser).id === "string" ); } function parseApiResponse(data: unknown): ApiUser { if (!isApiUser(data)) { throw new TypeError("Invalid API response shape"); } return data; // fully typed from here } The unknown version catches bugs at compile time. The any version catches them in production at 2 AM. AI loves isLoading?: boolean; error?: string; data?: T — three independent flags that can represent impossible states like "loading and errored simultaneously." Discriminated unions make illegal states unrepresentable. The rule: Use discriminated unions with a `status` or `kind` field for any type that represents multiple states. Never use parallel boolean/optional fields to represent mutually exclusive states. Every branch of the union must be handled in switch statements (enforce with `never` in the default case). Bad — boolean soup that allows impossible states: interface RequestState { isLoading: boolean; isError: boolean; error?: string; data?: T; } function renderUser(state: RequestState) { if (state.isLoading) return ; if (state.isError) return ; return ; // non-null assertion — dangerous } Good — discriminated union, every state explicit: type RequestState = | { status: "idle" } | { status: "loading" } | { status: "error"; error: string } | { status: "success"; data: T }; function renderUser(state: RequestState) { switch (state.status) { case "idle": return null; case "loading": return ; case "error": return ; case "success": return ; default: const _exhaustive: never = state; return _exhaustive; } } No non-null assertions. No impossible states. The never check guarantees you handle every variant — add a new status and the compiler tells you every place that needs updating. AI generates generics with no constraints, which means T could be anything — a string, a function, undefined. Without constraints, generic functions provide zero type safety beyond "something goes in, something comes out." The rule: Every generic type parameter must have a constraint using `extends`. Use `extends Record` for objects, `extends string` for string subtypes, `extends (...args: unknown[]) => unknown` for functions. Document what the constraint means with a JSDoc comment if non-obvious. Bad — unconstrained generic, no safety: function merge(a: T, b: U): T & U { return { ...a, ...b }; // T could be a number — spread fails at runtime } function getProperty(obj: T, key: string): any { return (obj as any)[key]; // back to any-land } Good — constrained generics, compile-time safety: function merge, U extends Record>( a: T, b: U ): T & U { return { ...a, ...b }; } function getProperty, K extends keyof T>( obj: T, key: K ): T[K] { return obj[key]; // return type is exact } // Usage: fully typed const result = getProperty({ name: "Ada", age: 36 }, "name"); // type: string Constrained generics give you autocompletion on the call site. The compiler catches invalid keys before runtime. catch (e: any) AI-generated catch blocks are a black hole for type information. The error is always any or unknown and the response is always console.log(e.message) — which crashes if e is a string or number. The rule: Define custom error classes for each domain. Extend the built-in Error class. In catch blocks, always narrow with `instanceof` checks before accessing properties. Never re-throw plain strings — always throw Error subclass instances. Use a Result type for recoverable errors instead of try/catch. Bad — untyped error handling: async function fetchUser(id: string) { try { const res = await fetch(`/api/users/${id}`); const data = await res.json(); return data; } catch (e) { console.log(e.message); // e is unknown — .message may not exist return null; // caller has no idea what went wrong } } Good — typed errors with Result pattern: class ApiError extends Error { constructor( message: string, public readonly statusCode: number, public readonly endpoint: string ) { super(message); this.name = "ApiError"; } } class NetworkError extends Error { constructor(message: string, public readonly cause?: unknown) { super(message); this.name = "NetworkError"; } } type Result = | { ok: true; data: T } | { ok: false; error: E }; async function fetchUser(id: string): Promise> { try { const res = await fetch(`/api/users/${id}`); if (!res.ok) { return { ok: false, error: new ApiError(`HTTP ${res.status}`, res.status, `/api/users/${id}`) }; } const data: User = await res.json(); return { ok: true, data }; } catch (e) { return { ok: false, error: new NetworkError("Failed to reach API", e) }; } } // Caller is forced to handle both paths const result = await fetchUser("123"); if (!result.ok) { if (result.error instanceof ApiError && result.error.statusCode === 404) { return ; } return ; } return ; The caller can't forget to handle errors. The type system shows exactly what can go wrong. AI generates inline types in function signatures, or worse, returns untyped objects. When the same shape appears in three places, you get three slightly different anonymous types that drift over time. The rule: Define named interfaces for all data shapes used at module boundaries (function params, return types, API contracts). Use `interface` for object shapes that may be extended. Use `type` for unions, intersections, and mapped types. Never use inline object types in function signatures. Bad — inline types, no shared contract: async function createOrder( items: { productId: string; quantity: number; price: number }[], customer: { id: string; email: string; tier: string } ): Promise { // implementation } async function sendConfirmation( order: { orderId: string; total: number }, customer: { email: string } ) { // slightly different shape — bug waiting to happen } Good — shared interfaces at module boundary: interface OrderItem { productId: string; quantity: number; price: number; } interface Customer { id: string; email: string; tier: "free" | "pro" | "enterprise"; } interface Order { orderId: string; total: number; status: "pending" | "confirmed" | "shipped"; } async function createOrder(items: OrderItem[], customer: Customer): Promise { // implementation } async function sendConfirmation(order: Order, customer: Customer): Promise { // same interfaces — guaranteed consistency } One source of truth for each shape. Rename a field and the compiler finds every usage. The tier and status fields use literal unions instead of bare string, catching invalid values at compile time. Readonly, Pick, and Omit for Derived Types AI copies entire interfaces and deletes fields when it needs a subset. This creates parallel types that drift apart. TypeScript's utility types derive types from a single source of truth. The rule: Use Pick, Omit, Partial, Required, and Readonly to derive types from existing interfaces. Never manually duplicate fields from one interface into another. Use Readonly for function parameters that should not be mutated. Use Required when converting optional fields to mandatory. Bad — duplicated interface with drifting fields: interface User { id: string; name: string; email: string; passwordHash: string; createdAt: Date; updatedAt: Date; } // Manually duplicated — will drift when User changes interface PublicUser { id: string; name: string; email: string; createdAt: Date; } // Another manual copy interface UserUpdate { name?: string; email?: string; } Good — derived types from single source: interface User { id: string; name: string; email: string; passwordHash: string; createdAt: Date; updatedAt: Date; } type PublicUser = Omit; type UserUpdate = Partial>; type UserCreateInput = Omit; function sanitizeUser(user: Readonly): PublicUser { const { passwordHash, updatedAt, ...publicFields } = user; return publicFields; } Add a field to User and every derived type updates automatically. Readonly prevents accidental mutation inside functions. No parallel types to keep in sync. Here's a single block you can drop into your .cursorrules or .cursor/rules/typescript.mdc: # TypeScript Rules ## Type Safety - Never use `any`. Use `unknown` and narrow with type guards. - Use `Record` for arbitrary objects. ## State Modeling - Use discriminated unions with a `status` or `kind` field for multi-state types. - Never use parallel boolean flags for mutually exclusive states. - Handle all branches in switch statements; enforce exhaustiveness with `never`. ## Generics - Every generic parameter must have a constraint via `extends`. - Document non-obvious constraints with JSDoc. ## Error Handling - Define custom Error subclasses for each domain. - In catch blocks, narrow errors with `instanceof` before accessing properties. - Use Result for recoverable errors instead of try/catch. ## Interfaces - Define named interfaces for all data shapes at module boundaries. - Use `interface` for extendable object shapes, `type` for unions and intersections. - Never use inline object types in function signatures. ## Derived Types - Use Pick, Omit, Partial, Required, Readonly to derive types. - Never manually duplicate fields from one interface to another. - Use Readonly for function params that must not be mutated. These 6 rules cover the patterns where AI coding assistants fail most often in TypeScript projects. Add them to your .cursorrules or CLAUDE.md and the difference is immediate — fewer any casts, stronger type safety, and code that catches bugs at compile time instead of production. I've packaged these rules (plus 44 more covering React, Next.js, Node.js, testing, and API patterns) into a ready-to-use rules pack: Cursor Rules Pack v2 Drop it into your project directory and stop fighting your AI assistant.
