// Config Record
>TypeScript Node.js Backend
Claude Code backend-focused TypeScript conventions for Fastify services, typed validation, Drizzle, and observability.
author:
dotmd Team
license:CC0
published:Feb 23, 2026
// Installation
>Add this file to your project repository:
- Claude Code--path=
CLAUDE.md
// File Content
CLAUDE.md
1# CLAUDE.md — TypeScript Node.js Backend23This is a backend API service built with Fastify and TypeScript. No frontend code lives here.45## Project Structure67```8src/9├── server.ts # Fastify instance creation + plugin registration10├── main.ts # Entry point — starts the server11├── routes/12│ ├── index.ts # Auto-loader or manual route registration13│ └── users/14│ ├── handlers.ts # Route handler functions15│ ├── schemas.ts # Request/response schemas (TypeBox)16│ └── index.ts # Route definitions (fastify.register)17├── plugins/18│ ├── auth.ts # Authentication plugin (fastify-plugin wrapped)19│ ├── database.ts # DB connection lifecycle20│ └── error-handler.ts # Centralized error handling21├── db/22│ ├── schema.ts # Drizzle table definitions23│ ├── migrations/ # Generated migration SQL files24│ └── index.ts # Drizzle client instance25├── services/ # Business logic — no HTTP concepts here26├── lib/27│ ├── errors.ts # Typed application errors28│ ├── env.ts # Type-safe environment config29│ └── logger.ts # Pino logger config (Fastify uses this)30└── types/ # Shared TypeScript types and interfaces31```3233Keep route handlers thin. Business logic belongs in `src/services/`. Handlers parse input, call a service, return output.3435## Commands3637```bash38# Development39pnpm dev # tsx watch src/main.ts40pnpm build # tsc --project tsconfig.build.json41pnpm start # node dist/main.js4243# Database44pnpm drizzle-kit generate # Generate migration from schema changes45pnpm drizzle-kit migrate # Apply pending migrations46pnpm drizzle-kit studio # Visual DB browser at https://local.drizzle.studio4748# Testing49pnpm test # vitest run50pnpm test:watch # vitest (watch mode)51pnpm test -- src/routes/users # Run tests in a specific directory5253# Linting & Formatting54pnpm lint # eslint src/55pnpm format # prettier --write src/56pnpm typecheck # tsc --noEmit57```5859## TypeScript Rules6061The `tsconfig.json` uses strict mode. Follow these without exception:6263- **No `any`.** Use `unknown` and narrow with type guards. If you need a generic container, use a type parameter.64- **No non-null assertions (`!`).** Handle the `null`/`undefined` case explicitly.65- **No `@ts-ignore` or `@ts-expect-error`** unless there is a comment explaining the upstream bug it works around.66- **Use `satisfies` over `as`** when you want type checking without widening: `const config = { ... } satisfies AppConfig`.67- **Return types on exported functions.** Inferred types are fine for local/private functions.6869```jsonc70// tsconfig.json essentials — these should all be true71{72 "compilerOptions": {73 "strict": true,74 "noUncheckedIndexedAccess": true, // arr[0] is T | undefined75 "exactOptionalPropertyTypes": true, // {a?: string} ≠ {a: string | undefined}76 "noPropertyAccessFromIndexSignature": true77 }78}79```8081## Fastify Patterns8283### Route Registration8485Routes are Fastify plugins. Register them with a prefix:8687```typescript88// src/routes/users/index.ts89import type { FastifyPluginAsync } from "fastify";90import { listUsers, createUser } from "./handlers.js";91import { CreateUserSchema, ListUsersSchema } from "./schemas.js";9293const usersRoutes: FastifyPluginAsync = async (fastify) => {94 fastify.get("/", { schema: ListUsersSchema }, listUsers);95 fastify.post("/", { schema: CreateUserSchema }, createUser);96};9798export default usersRoutes;99100// src/server.ts101app.register(usersRoutes, { prefix: "/api/users" });102```103104### Handler Signature105106Always type the request with the schema generic — don't manually type `req.body`:107108```typescript109import type { FastifyRequest, FastifyReply } from "fastify";110import type { CreateUserBody } from "./schemas.js";111112export async function createUser(113 request: FastifyRequest<{ Body: CreateUserBody }>,114 reply: FastifyReply,115): Promise<void> {116 const user = await userService.create(request.body);117 reply.status(201).send(user);118}119```120121**Do not** return the value from handlers. Use `reply.send()` explicitly — returning a value silently changes serialization behavior.122123### Schema Validation with TypeBox124125Fastify integrates with TypeBox natively. Define schemas next to routes:126127```typescript128// src/routes/users/schemas.ts129import { Type, type Static } from "@sinclair/typebox";130131const CreateUserBody = Type.Object({132 email: Type.String({ format: "email" }),133 name: Type.String({ minLength: 1, maxLength: 100 }),134});135type CreateUserBody = Static<typeof CreateUserBody>;136137export const CreateUserSchema = {138 body: CreateUserBody,139 response: {140 201: Type.Object({141 id: Type.String({ format: "uuid" }),142 email: Type.String(),143 name: Type.String(),144 }),145 },146};147```148149<!-- If using Express/NestJS: use Zod instead of TypeBox. Example:150```typescript151import { z } from "zod";152const CreateUserBody = z.object({153 email: z.string().email(),154 name: z.string().min(1).max(100),155});156type CreateUserBody = z.infer<typeof CreateUserBody>;157```158Express needs express-zod-api or manual `.parse()` in middleware.159NestJS uses class-validator decorators by default, but zod-nestjs works too. -->160161### Plugins (Decorators, Hooks)162163Wrap plugins with `fastify-plugin` when they should apply to the parent scope:164165```typescript166import fp from "fastify-plugin";167import type { FastifyPluginAsync } from "fastify";168169const authPlugin: FastifyPluginAsync = async (fastify) => {170 fastify.decorateRequest("userId", "");171 fastify.addHook("onRequest", async (request, reply) => {172 const token = request.headers.authorization?.replace("Bearer ", "");173 if (!token) {174 reply.status(401).send({ error: "Unauthorized" });175 return;176 }177 request.userId = await verifyToken(token);178 });179};180181export default fp(authPlugin, { name: "auth" });182```183184**Critical:** Without `fastify-plugin`, the decorator/hook is scoped to the child context only. If auth isn't applying to your routes, this is almost always why.185186## Database (Drizzle ORM)187188```typescript189// src/db/schema.ts190import { pgTable, uuid, varchar, timestamp } from "drizzle-orm/pg-core";191192export const users = pgTable("users", {193 id: uuid("id").primaryKey().defaultRandom(),194 email: varchar("email", { length: 255 }).notNull().unique(),195 name: varchar("name", { length: 100 }).notNull(),196 createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),197});198```199200```typescript201// src/db/index.ts202import { drizzle } from "drizzle-orm/node-postgres";203import * as schema from "./schema.js";204import { env } from "../lib/env.js";205206export const db = drizzle(env.DATABASE_URL, { schema });207```208209- Always use parameterized queries. Drizzle does this by default — never use `sql.raw()` with user input.210- Put complex queries in `src/services/`, not in route handlers.211- Use transactions for multi-step writes: `await db.transaction(async (tx) => { ... })`.212213## Error Handling214215Define typed application errors. Do not throw raw `Error` objects with status codes:216217```typescript218// src/lib/errors.ts219export class AppError extends Error {220 constructor(221 public readonly statusCode: number,222 message: string,223 public readonly code: string,224 ) {225 super(message);226 this.name = "AppError";227 }228}229230export class NotFoundError extends AppError {231 constructor(resource: string, id: string) {232 super(404, `${resource} ${id} not found`, "NOT_FOUND");233 }234}235236export class ConflictError extends AppError {237 constructor(message: string) {238 super(409, message, "CONFLICT");239 }240}241```242243```typescript244// src/plugins/error-handler.ts — register this globally245fastify.setErrorHandler((error, request, reply) => {246 if (error instanceof AppError) {247 reply.status(error.statusCode).send({248 error: error.code,249 message: error.message,250 });251 return;252 }253254 // Fastify validation errors have a .validation property255 if (error.validation) {256 reply.status(400).send({257 error: "VALIDATION_ERROR",258 message: error.message,259 });260 return;261 }262263 // Unexpected errors — log full details, return generic message264 request.log.error(error);265 reply.status(500).send({266 error: "INTERNAL_SERVER_ERROR",267 message: "An unexpected error occurred",268 });269});270```271272Services throw typed errors. Handlers don't catch them — the error handler plugin does.273274## Environment Config275276Load and validate env vars once at startup. Fail fast on missing config:277278```typescript279// src/lib/env.ts280import { Type, type Static } from "@sinclair/typebox";281import { Value } from "@sinclair/typebox/value";282283const EnvSchema = Type.Object({284 NODE_ENV: Type.Union([Type.Literal("development"), Type.Literal("production"), Type.Literal("test")]),285 PORT: Type.Number({ default: 3000 }),286 DATABASE_URL: Type.String(),287 JWT_SECRET: Type.String({ minLength: 32 }),288});289type Env = Static<typeof EnvSchema>;290291function loadEnv(): Env {292 const raw = {293 NODE_ENV: process.env.NODE_ENV,294 PORT: process.env.PORT ? Number(process.env.PORT) : undefined,295 DATABASE_URL: process.env.DATABASE_URL,296 JWT_SECRET: process.env.JWT_SECRET,297 };298 return Value.Decode(EnvSchema, raw); // Throws with detailed errors on missing/invalid vars299}300301export const env = loadEnv();302```303304## Logging305306Fastify uses pino by default. Configure it at server creation:307308```typescript309const app = Fastify({310 logger: {311 level: env.LOG_LEVEL ?? "info",312 ...(env.NODE_ENV === "development" && {313 transport: { target: "pino-pretty" },314 }),315 },316});317```318319- Use `request.log` inside handlers — it includes the request ID automatically.320- Use `app.log` outside of request context.321- **Never** use `console.log` in production code. It bypasses structured logging.322- Log objects, not string interpolation: `request.log.info({ userId, action: "created" })` not `request.log.info(\`User ${userId} created\`)`.323324## Testing325326Use vitest + light-my-request (built into Fastify):327328```typescript329// src/routes/users/__tests__/handlers.test.ts330import { describe, it, expect, beforeAll, afterAll } from "vitest";331import { buildApp } from "../../../server.js";332333describe("POST /api/users", () => {334 let app: Awaited<ReturnType<typeof buildApp>>;335336 beforeAll(async () => {337 app = await buildApp(); // Returns configured Fastify instance without calling .listen()338 });339340 afterAll(async () => {341 await app.close();342 });343344 it("creates a user and returns 201", async () => {345 const response = await app.inject({346 method: "POST",347 url: "/api/users",348 payload: { email: "test@example.com", name: "Test User" },349 });350351 expect(response.statusCode).toBe(201);352 expect(response.json()).toMatchObject({353 email: "test@example.com",354 name: "Test User",355 });356 });357358 it("returns 400 on invalid email", async () => {359 const response = await app.inject({360 method: "POST",361 url: "/api/users",362 payload: { email: "not-an-email", name: "Test" },363 });364365 expect(response.statusCode).toBe(400);366 });367});368```369370For integration tests with a real database, use `@testcontainers/postgresql` to spin up PostgreSQL in Docker. Set `DATABASE_URL` from `container.getConnectionUri()` and run migrations before tests.371372## Anti-Patterns — Do Not373374- **Don't use `express-style` middleware in Fastify.** Fastify has hooks (`onRequest`, `preHandler`, `preSerialization`), not `app.use()`. The lifecycle is different. Read: https://fastify.dev/docs/latest/Reference/Lifecycle/375- **Don't put business logic in route handlers.** Handlers are glue — they call services.376- **Don't use `enum` in TypeScript.** Use `as const` objects or union types instead. Enums have surprising runtime behavior.377- **Don't import from `dist/`.** Always import from `src/` using `.js` extensions (TypeScript resolves these to `.ts` during compilation).378- **Don't use `default export` for non-route modules.** Named exports are searchable and refactor-safe.379- **Don't silence errors with empty catch blocks.** At minimum, log them.380- **Don't store secrets in code.** Use environment variables loaded through `src/lib/env.ts`.381- **Don't use `node:` prefix inconsistently.** Always use it: `import { readFile } from "node:fs/promises"`.382- **Don't create god services.** One service per domain concept.383384## Import Conventions385386```typescript387import { randomUUID } from "node:crypto"; // 1. Node built-ins (always use node: prefix)388import Fastify from "fastify"; // 2. External packages389import { db } from "@/db/index.js"; // 3. Internal absolute (path aliases)390import { CreateUserSchema } from "./schemas.js"; // 4. Internal relative391```392393Use `.js` extensions in all imports. TypeScript with `"moduleResolution": "nodenext"` requires them.394