· 18 min read

The Best Cursor Rules for TypeScript in 2026 — A Developer's Guide

Cursor is only as good as the rules you give it. Without a .cursorrules file, Cursor treats your TypeScript project like a generic codebase — it suggests any, skips runtime validation, uses as casts instead of type guards, and ignores the strict-mode flags you spent an hour configuring. The fix is cursor rules: explicit, version-controlled instructions that tell the AI how your team actually writes TypeScript. This guide covers the best cursor rules for TypeScript projects in 2026, with real examples you can copy into your project today.

I've been writing TypeScript in Cursor for over a year, and the single biggest improvement to my AI-assisted workflow wasn't a model upgrade or a new plugin. It was writing a proper .cursorrules file. The difference is dramatic: instead of fighting Cursor's suggestions on every third autocomplete, the AI starts generating code that matches your project's conventions from the start.

Below are the cursor rules for TypeScript that have had the most impact across my projects. Each one includes the actual rule text you'd put in your .cursorrules file, plus an explanation of why it matters and what problem it solves. These are extracted from a larger rules pack I maintain, and I'm sharing the TypeScript-specific ones here for free.

What Are Cursor Rules and Why They Matter for TypeScript

A .cursorrules file is a plain-text file at the root of your project that Cursor reads automatically. Everything you write in it becomes part of the system prompt that Cursor uses when generating code, reviewing files, or answering questions about your codebase. Think of it as a persistent set of instructions for your AI pair programmer.

For TypeScript specifically, cursor rules solve a problem that's unique to typed languages: the AI knows TypeScript syntax, but it doesn't know your type conventions. Without rules, Cursor will:

The best cursor rules for TypeScript are opinionated. They don't just say "use TypeScript" — they specify exactly how you use it. That's what makes them valuable: they encode the decisions a senior TypeScript developer would make, so the AI doesn't have to guess.

Rule 1: Enforce Strict Mode Properly

The first and most important cursor rule for any TypeScript project is to enforce strict mode — and not just the "strict": true baseline. The most impactful strict flags are the ones that aren't enabled by default.

Cursor Rule — tsconfig baseline

Enforce non-default strict flags that catch real bugs

This rule tells Cursor to generate code that's compatible with the strictest useful TypeScript configuration, including flags most teams miss.

## TSCONFIG BASELINE

The project uses these compilerOptions. All generated code must be compatible:

```json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true
  }
}
```

`noUncheckedIndexedAccess` is the most impactful non-default flag. It forces
you to handle the case where an array index or record key might be undefined.

Never use `any`. Never use `as` type assertions unless narrowing has been
exhausted and a comment explains why. Prefer `unknown` + type guards.

Why this matters: noUncheckedIndexedAccess is the flag that separates hobby TypeScript from production TypeScript. Without it, arr[0] returns T when it should return T | undefined. Cursor will generate code that accesses array elements and object keys without null checks unless you explicitly tell it this flag is on. This single rule prevents an entire class of runtime errors.

In practice, this rule changes Cursor's behavior immediately. Instead of generating const first = items[0], it generates const first = items[0]; if (!first) return. Instead of const value = obj[key] as string, it generates proper narrowing with a conditional check.

The exactOptionalPropertyTypes flag is another one most teams skip. It prevents you from assigning undefined to an optional property unless the type explicitly includes undefined. Without it, { name?: string } allows { name: undefined }, which is a subtle but real bug source when you serialize to JSON or pass to an API that treats missing keys differently from undefined values.

Rule 2: Zod Validation at Every Trust Boundary

TypeScript's type system is erased at runtime. This means every piece of data that enters your application from the outside world — API responses, user input, environment variables, webhook payloads, localStorage reads — is effectively unknown at runtime, regardless of what type you've assigned to it. Zod bridges this gap.

This is one of the best cursor rules for TypeScript because it prevents the most common source of production bugs: trusting that external data matches your types.

Cursor Rule — Zod validation

Validate at every trust boundary, derive types from schemas

This rule ensures Cursor always generates runtime validation for external data and derives TypeScript types from Zod schemas instead of duplicating type definitions.

## ZOD VALIDATION PATTERNS

Validate at every trust boundary: API input, API responses, environment
variables, webhook payloads, localStorage reads.

```typescript
// lib/schemas/user.ts
import { z } from 'zod'

export const UserSchema = z.object({
  id: z.string().cuid(),
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: z.enum(['admin', 'member', 'viewer']),
  createdAt: z.coerce.date(),
})

// Derive types from schemas — never duplicate
export type User = z.infer<typeof UserSchema>

// Partial schemas for updates
export const UpdateUserSchema = UserSchema.pick({
  name: true, role: true
}).partial()
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>
```

```typescript
// Validating API responses from external services
async function fetchGithubUser(username: string): Promise<GithubUser> {
  const res = await fetch(`https://api.github.com/users/${username}`)
  const raw = await res.json()
  return GithubUserSchema.parse(raw) // throws if shape is wrong
}

// Safer: use safeParse and handle the failure
const result = GithubUserSchema.safeParse(raw)
if (!result.success) {
  throw new Error(`Unexpected GitHub API shape: ${result.error.message}`)
}
return result.data
```

Never define a TypeScript type manually when a Zod schema exists for the
same data. Always use `z.infer` to derive the type from the schema.

Why this matters: Without this rule, Cursor will generate const data = await res.json() as User — a type assertion that provides zero runtime safety. With the rule, Cursor generates UserSchema.parse(await res.json()), which actually validates the data shape. The z.infer pattern is critical too: it means you have one source of truth for both the runtime validator and the TypeScript type.

The safeParse vs parse distinction is worth calling out. parse throws on invalid data, which is appropriate for webhooks and API routes where you want to reject bad input immediately. safeParse returns a result object, which is better when you want to handle the error gracefully — like showing a validation message to a user or logging a warning about an unexpected third-party API change.

A well-written cursor rule for TypeScript will make Cursor default to safeParse for user-facing code and parse for system-to-system boundaries. The rule above establishes the pattern; the AI infers the context.

Rule 3: Discriminated Unions Over Optional Fields

This is the rule that separates intermediate TypeScript from advanced TypeScript. Most developers model state with optional fields: isLoading: boolean; data?: User; error?: string. The problem is that this type allows impossible states — like { isLoading: true, data: someUser, error: "something broke" } — which are a constant source of UI bugs.

Cursor Rule — Discriminated unions

Model state so impossible combinations are unrepresentable

This rule tells Cursor to use discriminated unions for any type that represents state with mutually exclusive conditions.

## DISCRIMINATED UNION PATTERNS

Use discriminated unions to model state that can't be in an invalid
combination.

```typescript
// Instead of optional fields that create impossible states:
// BAD
type RequestState = {
  isLoading: boolean
  data?: User
  error?: string
}

// GOOD — only valid combinations are expressible
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string }

// Usage forces exhaustive handling
function render(state: RequestState<User>) {
  switch (state.status) {
    case 'idle': return null
    case 'loading': return <Spinner />
    case 'success': return <UserCard user={state.data} />
    case 'error': return <ErrorMessage message={state.error} />
  }
}
```

Prefer discriminated unions over boolean flags for any state that has
more than two possible conditions. The discriminant field should always
be named `status`, `type`, or `kind` for consistency.

Why this matters: Without this rule, Cursor generates types with optional fields and boolean flags every time. The discriminated union pattern makes the compiler enforce that you handle every state, and it narrows the type automatically in each branch — so state.data is only accessible when state.status === 'success'. This eliminates the entire category of "I forgot to check if data exists before rendering it" bugs.

The naming convention in the rule — using status, type, or kind as the discriminant — matters more than you'd think. When every discriminated union in your codebase uses the same field name, Cursor learns the pattern faster and generates consistent code across files. Without this convention, you end up with state in one file, variant in another, and mode in a third.

Rule 4: Type Narrowing and Exhaustive Checks

Type narrowing is how you go from a broad type to a specific one without using as assertions. TypeScript narrows automatically inside if blocks, switch cases, and type guard functions. The problem is that Cursor often reaches for as instead, because it's shorter. This rule prevents that.

Cursor Rule — Type narrowing

Prefer type predicates and exhaustive switches over assertions

## TYPE NARROWING BEST PRACTICES

```typescript
// Prefer type predicates over casting
function isUser(value: unknown): value is User {
  return UserSchema.safeParse(value).success
}

// Use exhaustive checks with never
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`)
}

type Shape = 'circle' | 'square' | 'triangle'
function area(shape: Shape, size: number): number {
  switch (shape) {
    case 'circle': return Math.PI * size ** 2
    case 'square': return size ** 2
    case 'triangle': return (Math.sqrt(3) / 4) * size ** 2
    default: return assertNever(shape)
    // compile error if a case is missing
  }
}

// Nullish narrowing — always check before accessing
function getUserDisplayName(user: User | null): string {
  if (!user) return 'Anonymous'
  return user.name ?? user.email
}
```

Never use `as` type assertions for narrowing. Use type predicates
(value is Type), in-operator narrowing, or instanceof checks instead.
The only acceptable use of `as` is `as const` for literal types.

Why this matters: The assertNever pattern is the most underused TypeScript feature. When you add a new variant to a union type, every switch statement that uses assertNever in its default case will produce a compile error until you handle the new case. This makes union types self-documenting and prevents stale code paths. Without this rule, Cursor defaults to a default case that silently ignores new variants.

The combination of Zod type predicates and the assertNever pattern is particularly powerful. Your type guard uses Zod for runtime validation (safeParse), and your switch statements use assertNever for compile-time completeness checks. Together, they give you both runtime safety and compile-time exhaustiveness — the two pillars of production TypeScript.

Rule 5: Typed API Responses

Every API endpoint in your application should return a consistent response shape. Without a rule for this, Cursor will generate a different response format for every endpoint — sometimes { user: {...} }, sometimes { data: {...} }, sometimes just the raw object. This makes client-side code unpredictable and error handling inconsistent.

Cursor Rule — API response typing

A shared response envelope used everywhere

## API RESPONSE TYPING

Define a shared response envelope and use it everywhere.

```typescript
// types/api.ts
export type ApiSuccess<T> = { data: T; error?: never }
export type ApiError = { data?: never; error: string; code?: string }
export type ApiResponse<T> = ApiSuccess<T> | ApiError

// Route handler usage
return NextResponse.json<ApiResponse<User>>(
  { data: user }, { status: 200 }
)
return NextResponse.json<ApiResponse<never>>(
  { error: 'Not found' }, { status: 404 }
)

// Client-side fetch helper with type narrowing
async function apiFetch<T>(url: string): Promise<T> {
  const res = await fetch(url)
  const json: ApiResponse<T> = await res.json()
  if (json.error) throw new Error(json.error)
  return json.data
}
```

Every API route must return ApiResponse<T>. The client-side fetch
helper narrows the union automatically. Never return raw objects
from API routes.

Why this matters: The error?: never trick is key. It makes ApiSuccess and ApiError mutually exclusive at the type level — a success response literally cannot have an error field, and vice versa. This means TypeScript narrows the union correctly when you check if (json.error). Without this, the compiler can't tell which branch you're in.

This pattern becomes especially powerful when combined with the Zod validation rule from earlier. Your API routes validate incoming data with Zod, then return typed responses with ApiResponse<T>. Your client-side code uses apiFetch<T> to get typed data back. The entire pipeline is type-safe, from request to response.

Rule 6: Environment Variable Validation

This is one of those cursor rules for TypeScript that seems minor until it saves you from a 3 AM production incident. Environment variables are string | undefined in TypeScript by default. Most developers access them directly with process.env.DATABASE_URL and don't find out they're missing until the code crashes at runtime.

Cursor Rule — env validation

Validate at import time, crash early on bad config

## ENVIRONMENT VARIABLES

```typescript
// lib/env.ts — validate at import time, crash early on bad config
import { z } from 'zod'

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  NEXTAUTH_SECRET: z.string().min(32),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  NEXT_PUBLIC_APP_URL: z.string().url(),
})

export const env = envSchema.parse(process.env)
```

Import `env` from this module everywhere. Never use
`process.env.VARIABLE_NAME` directly in application code.

When generating code that reads environment variables, always import
from `@/lib/env` instead of reading `process.env` directly.

Why this matters: This rule changes when errors surface. Without it, a missing STRIPE_SECRET_KEY doesn't fail until a user tries to check out — which could be hours or days after deploy. With Zod validation at import time, the app crashes immediately on startup if any env var is missing or malformed. You find out in CI, not in production. The startsWith validators are especially useful for catching "I pasted my test key into production" mistakes.

Rule 7: The satisfies Operator

The satisfies operator was introduced in TypeScript 4.9 and it's still underused in 2026. It validates that a value matches a type while preserving the literal types for inference. This is the best cursor rule for TypeScript when you have configuration objects, route maps, or any constant that should conform to a shape but also needs narrow type inference.

Cursor Rule — satisfies operator

Validate shape while keeping literal types

## SATISFIES OPERATOR

Use `satisfies` to validate a value matches a type while keeping the
literal type for inference.

```typescript
type Routes = Record<string, { path: string; title: string }>

// `satisfies` validates the shape but keeps the literal types
const routes = {
  dashboard: { path: '/dashboard', title: 'Dashboard' },
  settings: { path: '/settings', title: 'Settings' },
} satisfies Routes

// routes.dashboard.path is '/dashboard' (literal), not string
// Without satisfies: routes.dashboard.path would be string
```

Use `satisfies` for config objects, route maps, theme definitions,
and any constant where both shape validation and narrow inference
matter. Prefer `satisfies` over `as const` when you also need
shape validation.

Why this matters: Without satisfies, you have two bad options. Option A: annotate the type with const routes: Routes = {...}, which validates the shape but widens all literals to string. Option B: use as const, which preserves literals but doesn't validate the shape at all. satisfies gives you both: type validation AND narrow inference. Cursor almost never uses satisfies without a rule telling it to.

Bonus: Generic Utility Types

The built-in TypeScript utility types (Omit, Pick, Partial) are useful but have gaps. Omit, for example, doesn't error if you try to omit a key that doesn't exist on the type — which means typos in Omit calls silently do nothing. These custom utility types close those gaps.

Cursor Rule — utility types

Stricter versions of built-in TypeScript utilities

## GENERIC UTILITY TYPES

```typescript
// Stricter Omit that errors on nonexistent keys
type StrictOmit<T, K extends keyof T> = Omit<T, K>

// Make specific keys required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>

// Deep partial for patch operations
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

// Paginated API response shape
type PaginatedResponse<T> = {
  data: T[]
  pagination: {
    page: number
    pageSize: number
    total: number
    totalPages: number
  }
}
```

Use `StrictOmit` instead of `Omit` throughout the project. Use
`RequireFields` when a function needs certain optional fields to be
present. Use `DeepPartial` for PATCH/update operations only.

Why this matters: StrictOmit is a one-line change that prevents an entire class of bugs. With the built-in Omit<User, 'nmae'> (typo), TypeScript silently does nothing — the resulting type still has all fields. With StrictOmit<User, 'nmae'>, you get a compile error because 'nmae' is not a key of User. These small improvements compound across a codebase.


How to Set Up Cursor Rules in Your Project

Setting up cursor rules takes about two minutes. There are two approaches, and you can use both at the same time.

Option A: Single .cursorrules file (recommended for most projects)

Create a .cursorrules file at your project root. Paste all your rules into this one file. Cursor reads it automatically for every file in the project — no configuration needed.

your-project/
├── .cursorrules    ← paste all rules here
├── src/
├── package.json
└── tsconfig.json

This is the simplest approach and works well for most TypeScript projects. All rules are always active, which means the AI always has your full conventions in context.

Option B: Modular rules (Cursor 0.40+)

For larger projects, you can split rules into separate files in .cursor/rules/. Cursor applies the relevant rule file based on what you're editing. TypeScript rules activate when you're in a .ts file. Stripe rules activate when you open a billing file. This reduces noise and keeps the context focused.

your-project/
├── .cursor/
│   └── rules/
│       ├── typescript-strict.md
│       ├── nextjs-app-router.md
│       ├── stripe-integration.md
│       ├── prisma-patterns.md
│       └── testing.md
├── src/
└── package.json

The modular approach is ideal for full-stack projects where you have different conventions for different parts of the stack. A .cursorrules root file and .cursor/rules/ files can coexist — the root file acts as a global baseline, and the modular files add domain-specific rules.

Tips for writing effective cursor rules


Beyond TypeScript: What Else Should Your Cursor Rules Cover?

The rules above focus on TypeScript type safety, but a production project needs rules for more than types. The best cursor rules packs also cover:

Writing rules for all of these from scratch takes significant time. You need to test them across different files and scenarios, refine the wording until the AI consistently follows them, and keep them updated as frameworks and libraries evolve.

Get the Full Cursor Rules Pack — $19

The rules above are samples from the Cursor Rules Pack. The full pack includes 6 comprehensive rule files covering TypeScript strict mode, Next.js App Router, Prisma patterns, Stripe integration, Tailwind components, and testing — plus a master .cursorrules file that works as a drop-in for any Next.js + TypeScript project.

Every rule includes real code examples, BAD/GOOD comparisons, and specific instructions that Cursor actually follows. Drop them into your project and immediately get AI-generated code that matches how senior engineers build.

Buy the Cursor Rules Pack — $19

Instant download. 6 rule files + master .cursorrules. No subscription.

Just want to try a single .cursorrules file? Grab the $2 sampler — includes one production-ready cursorrules file you can drop in immediately.

More Developer Tools from ZeroDayKit