AI Coding Rules for TypeScript Backend Projects
Practical AI coding rules for TypeScript backend projects covering Express, Fastify, Hono, Zod validation, Drizzle ORM, error handling, and project structure.
AI Coding Rules for TypeScript Backend Projects
TypeScript on the backend has its own set of patterns that differ from frontend work. You have HTTP frameworks with different routing conventions, database ORMs with their own query builders, validation libraries, middleware chains, and authentication flows. AI coding tools trained on a mix of Express tutorials and frontend React code will default to patterns that don't match your stack.
Without explicit rules, you get Express-style code when you're using Hono. You get raw SQL strings when you should be using Drizzle. You get untyped request handlers that throw away the entire point of using TypeScript in the first place.
This guide covers the rules you need for every layer of a TypeScript backend project: project structure, framework conventions, validation, database access, error handling, middleware, and authentication. Each rule is ready to drop into your Cursor rules file, CLAUDE.md, or any other AI tool's configuration.
Runtime and tooling
Start every rules file by declaring your runtime and tooling. AI models will default to CommonJS, older Node.js APIs, and npm unless you tell them otherwise.
## Runtime and tooling
- Runtime: Node.js 22 (or Bun 1.x, or Cloudflare Workers)
- Language: TypeScript 5.7 (strict mode, ESM only)
- Package manager: pnpm (not npm, not yarn)
- Bundler: tsup or tsx for development
- Linter: eslint with @typescript-eslint
- Formatter: prettier
- Test runner: vitest
This single block prevents the most common mistakes: generating require() instead of import, using callback-style Node.js APIs instead of promise-based ones, and reaching for npm when your lockfile is pnpm. If you're running on Cloudflare Workers or Bun, state it here so the AI avoids Node-specific APIs like fs or child_process that don't exist in those runtimes.
Project structure
Backend TypeScript projects have more structural variation than frontend ones. There's no equivalent to Next.js's app/ directory that everyone follows. Define your layout explicitly.
## Project structure
- Entry point: src/index.ts
- Routes: src/routes/[resource].ts (one file per resource)
- Business logic: src/services/[resource].ts
- Database schema: src/db/schema.ts
- Database queries: src/db/queries/[resource].ts
- Middleware: src/middleware/[name].ts
- Validation schemas: src/validators/[resource].ts
- Type definitions: src/types/[name].ts
- Tests: src/**/*.test.ts (co-located with source files)
- Config: src/config.ts (single file, env var validation on startup)
Do NOT put route handlers, business logic, and database queries in the same file.
Do NOT create a utils/ catch-all directory. Be specific about where shared code goes.
The separation between routes, services, and database queries is what matters most here. Without it, AI tools will dump everything into the route handler: validation, business logic, database calls, and error formatting in one 80-line function. Forcing the split makes each layer testable on its own.
Framework conventions: Express, Fastify, and Hono
Each framework has different patterns for defining routes, handling requests, and managing middleware. The AI needs to know which one you're using and how you use it.
Express
## HTTP framework: Express 5
- Use express.Router() for route grouping, one router per resource
- All route handlers are async (Express 5 catches rejected promises automatically, no asyncHandler wrapper needed)
- Access parsed body via req.body (already typed via validation middleware)
- Do NOT use app.get/app.post directly in the entry file, use routers
- Do NOT use req.params without validating first
const router = express.Router();
router.post("/", validate(createUserSchema), async (req, res) => {
const user = await userService.create(req.validated);
res.status(201).json({ data: user });
});
export default router;
Fastify
## HTTP framework: Fastify 5
- Use route schema validation with JSON Schema or TypeBox
- Type route handlers with RouteHandlerMethod or inline generics
- Register plugins for route groups using fastify.register()
- Return values from async handlers (preferred over reply.send() for cleaner error handling)
- Do NOT use Express-style middleware patterns
app.post<{ Body: CreateUserBody }>("/users", {
schema: { body: createUserSchema },
}, async (request, reply) => {
const user = await userService.create(request.body);
reply.status(201);
return { data: user };
});
Hono
## HTTP framework: Hono 4
- Use Hono() with route chaining or app.route() for grouping
- Use c.req.json() to parse request bodies
- Return responses with c.json()
- Use Hono's built-in middleware (cors, logger, etc.) before custom middleware
- Use the Zod validator middleware (@hono/zod-validator) for request validation
- Type context variables with new Hono<{ Variables: { user: User } }>()
const app = new Hono();
app.post("/users", zValidator("json", createUserSchema), async (c) => {
const validated = c.req.valid("json");
const user = await userService.create(validated);
return c.json({ data: user }, 201);
});
Pick the section that matches your framework and drop the others. Mixing framework patterns is one of the easiest ways to confuse AI tools. If you're using Hono, don't leave Express examples in your rules file.
Zod validation
Zod is the standard for runtime validation in TypeScript backends. Without rules, AI tools will sometimes skip validation entirely, use manual type assertions, or define schemas inline instead of in a shared location.
## Validation with Zod
- Every API endpoint validates its input with a Zod schema
- Schemas live in src/validators/[resource].ts, not inline in route handlers
- Use z.infer<typeof schema> to derive TypeScript types from schemas
- Validate request bodies, query parameters, and path parameters separately
- Use .transform() for data normalization (trimming strings, lowercasing emails)
- Use .refine() for custom validation that depends on business rules
- Do NOT use manual type assertions (as Type) to bypass validation
- Do NOT trust req.body or req.params without validation
// src/validators/user.ts
import { z } from "zod";
export const createUserSchema = z.object({
email: z.string().email().transform(s => s.toLowerCase().trim()),
name: z.string().min(1).max(100),
role: z.enum(["admin", "member"]).default("member"),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const userIdParam = z.object({
id: z.string().uuid(),
});
The key insight here is that Zod schemas serve double duty: they validate at runtime and generate TypeScript types at compile time. AI tools that skip Zod validation are also skipping type safety, because the TypeScript types derived from schemas are the source of truth for what your API actually accepts.
Database access: Drizzle and Prisma
Your ORM choice changes how queries are written, how schemas are defined, and where migrations live. Be explicit.
Drizzle ORM
## Database: Drizzle ORM + PostgreSQL
- Schema defined in src/db/schema.ts using drizzle-orm table builders
- Queries in src/db/queries/[resource].ts, not in route handlers or services
- Use the query builder (db.select().from().where()) for reads
- Use db.insert(), db.update(), db.delete() for writes
- Always use parameterized values, never string interpolation in queries
- Use transactions (db.transaction()) for multi-step writes
- Use .returning() to get inserted/updated rows back
- Run migrations with drizzle-kit: pnpm drizzle-kit push or pnpm drizzle-kit migrate
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function getUserByEmail(email: string) {
return db.query.users.findFirst({
where: eq(users.email, email),
with: { profile: true },
});
}
export async function createUser(input: CreateUserInput) {
const [user] = await db.insert(users).values(input).returning();
return user;
}
Prisma
## Database: Prisma + PostgreSQL
- Schema defined in prisma/schema.prisma
- Use PrismaClient from src/db/client.ts (singleton instance)
- Use include and select to control returned fields, never fetch full records unnecessarily
- Use transactions (prisma.$transaction()) for multi-step writes
- Use Prisma's generated types for all database-related interfaces
- Run migrations with: pnpm prisma migrate dev (local) or pnpm prisma migrate deploy (production)
import { prisma } from "@/db/client";
export async function getUserByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
include: { profile: true },
});
}
Whichever ORM you use, the critical rule is keeping queries out of route handlers. Route handlers should call service functions, service functions should call query functions, and query functions should talk to the database. This layering makes each piece testable and prevents the AI from generating 50-line route handlers that mix HTTP concerns with database logic.
Error handling
Error handling in backend projects needs to be consistent. Without rules, AI tools generate a different error pattern in every route handler: sometimes throwing, sometimes returning, sometimes logging, sometimes not.
## Error handling
- Define custom error classes in src/errors.ts
- Throw typed errors in services, catch and format them in middleware
- Never expose internal error details (stack traces, DB errors) to the client
- Always return consistent error shapes: { error: string, code: string }
- Log errors with structured logging (pino or similar), not console.log
// src/errors.ts
export class AppError extends Error {
constructor(
message: string,
public statusCode: number,
public code: string,
) {
super(message);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, "NOT_FOUND");
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400, "VALIDATION_ERROR");
}
}
// src/middleware/error-handler.ts
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message, code: err.code });
}
logger.error({ err, path: req.path }, "Unhandled error");
return res.status(500).json({ error: "Internal server error", code: "INTERNAL_ERROR" });
}
- Services throw errors: throw new NotFoundError("User")
- Route handlers do NOT catch errors manually, the error middleware handles it
- Do NOT use generic try/catch in every route handler
The centralized error handler pattern means route handlers stay clean. Services throw descriptive errors. The middleware catches them, logs them, and formats the response. AI tools love to add try/catch blocks everywhere, which is redundant when you have error middleware.
Middleware patterns
Middleware ordering and typing matter in backend projects. Without rules, AI tools add middleware in random order or skip it entirely.
## Middleware
Middleware executes in this order:
1. Request logging (pino-http or equivalent)
2. CORS
3. Body parsing (JSON, URL-encoded)
4. Authentication (verify JWT/session, attach user to context)
5. Route-specific validation (Zod schemas)
6. Route handler
7. Error handler (must be last)
- Authentication middleware attaches the user to req.user or context
- Validation middleware attaches validated data to req.validated or context
- Do NOT perform authentication checks inside route handlers
- Do NOT reorder middleware without understanding the dependency chain
This ordering prevents subtle bugs. CORS must run before authentication so preflight requests work. Body parsing must run before validation. The error handler must be last so it catches errors from every layer above it.
Authentication patterns
Authentication is security-critical. AI tools often generate insecure patterns: storing plain passwords, skipping token expiry checks, or trusting client-provided user IDs.
## Authentication
- Use JWT (jose library) or session-based auth (stored in database)
- Hash passwords with bcrypt or argon2, never store plain text
- Verify tokens in middleware, not in individual route handlers
- Check token expiry on every request
- Use refresh token rotation for long-lived sessions
- Attach the authenticated user to request context after verification
- Protected routes use the auth middleware: router.use(requireAuth)
- Public routes are explicitly marked in the router setup
- Do NOT trust user IDs from request bodies for authorization, use the authenticated user from context
- Do NOT store JWTs in localStorage (if serving a web client), use httpOnly cookies
// Middleware attaches the user
async function requireAuth(req, res, next) {
const token = extractToken(req);
if (!token) return res.status(401).json({ error: "Unauthorized", code: "UNAUTHORIZED" });
const payload = await verifyJWT(token);
req.user = await userService.getById(payload.sub);
next();
}
// Route handler uses req.user
router.get("/me", async (req, res) => {
res.json({ data: req.user });
});
The most important rule here is never trusting client-provided IDs for authorization. If a route updates a user's profile, it should use req.user.id from the authenticated context, not a user ID from the URL or request body.
A complete rules file for TypeScript backends
Here's a starter rules file for a typical TypeScript backend project:
# Project: [Your App Name]
## Stack
- Runtime: Node.js 22 (ESM only)
- Language: TypeScript 5.7 (strict mode)
- Framework: Hono 4
- Database: Drizzle ORM + PostgreSQL
- Validation: Zod
- Auth: JWT (jose)
- Testing: vitest
- Package manager: pnpm
## Structure
- Entry: src/index.ts
- Routes: src/routes/[resource].ts
- Services: src/services/[resource].ts
- DB schema: src/db/schema.ts
- DB queries: src/db/queries/[resource].ts
- Validators: src/validators/[resource].ts
- Middleware: src/middleware/[name].ts
- Errors: src/errors.ts
## Routing
- One route file per resource
- Validate all inputs with Zod middleware
- Return consistent shapes: { data } or { error, code }
## Database
- Queries in src/db/queries/, not in routes or services
- Use Drizzle query builder, no raw SQL
- Use transactions for multi-step writes
- Use .returning() for insert/update results
## Validation
- Zod schemas in src/validators/
- Use z.infer<typeof schema> for types
- Validate body, params, and query separately
- Use .transform() for normalization
## Error handling
- Custom error classes extend AppError
- Services throw errors, middleware catches them
- Never expose internal errors to clients
- Structured logging with pino
## Auth
- JWT verification in middleware
- User attached to request context
- Never trust client-provided IDs for authorization
- Protected routes use requireAuth middleware
## Do NOT
- Do not use CommonJS (require/module.exports)
- Do not use any as a TypeScript type
- Do not put database queries in route handlers
- Do not use console.log, use the logger
- Do not skip input validation on any endpoint
- Do not store secrets in code, use environment variables
- Do not trust req.body or req.params without Zod validation
Adapting rules to your framework
The rules above use Hono for the examples, but the patterns are framework-agnostic. The layered architecture (routes, services, queries), centralized error handling, Zod validation, and authentication middleware all apply whether you're using Express, Fastify, Hono, or Elysia.
What changes between frameworks is the syntax, not the structure. Swap the route handler examples for your framework's API, keep everything else the same. If you're working across both frontend and backend TypeScript, pair these rules with the React and Next.js rules guide to cover your full stack.
For the general principles behind writing effective rules for any language or framework, see the best practices guide. And if you want to see how other teams structure their rules files, check out real-world cursorrules examples.
Sharing backend rules across your team
Writing these rules once takes effort. Keeping them consistent across multiple services and multiple developers is the harder problem. One team member updates the error handling convention, another doesn't see the change, and now your services return errors in two different formats.
This is what localskills.sh is built for. Publish your TypeScript backend rules as a skill, and every service installs from the same source. When you update a pattern, publish a new version and everyone pulls it:
localskills install your-team/typescript-backend --target cursor claude windsurf
The format differences between Cursor, Claude Code, and Windsurf are handled for you. One skill, every tool. For details on how this works across different AI tools, see the CLAUDE.md guide or the Cursor rules guide.
Ready to standardize your TypeScript backend conventions across your team and every AI tool? Create your free account and publish your first skill today.
npm install -g @localskills/cli
localskills login
localskills publish