dotmd
// 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
    CLAUDE.md
// File Content
CLAUDE.md
1# CLAUDE.md — TypeScript Node.js Backend
2
3This is a backend API service built with Fastify and TypeScript. No frontend code lives here.
4
5## Project Structure
6
7```
8src/
9├── server.ts # Fastify instance creation + plugin registration
10├── main.ts # Entry point — starts the server
11├── routes/
12│ ├── index.ts # Auto-loader or manual route registration
13│ └── users/
14│ ├── handlers.ts # Route handler functions
15│ ├── 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 lifecycle
20│ └── error-handler.ts # Centralized error handling
21├── db/
22│ ├── schema.ts # Drizzle table definitions
23│ ├── migrations/ # Generated migration SQL files
24│ └── index.ts # Drizzle client instance
25├── services/ # Business logic — no HTTP concepts here
26├── lib/
27│ ├── errors.ts # Typed application errors
28│ ├── env.ts # Type-safe environment config
29│ └── logger.ts # Pino logger config (Fastify uses this)
30└── types/ # Shared TypeScript types and interfaces
31```
32
33Keep route handlers thin. Business logic belongs in `src/services/`. Handlers parse input, call a service, return output.
34
35## Commands
36
37```bash
38# Development
39pnpm dev # tsx watch src/main.ts
40pnpm build # tsc --project tsconfig.build.json
41pnpm start # node dist/main.js
42
43# Database
44pnpm drizzle-kit generate # Generate migration from schema changes
45pnpm drizzle-kit migrate # Apply pending migrations
46pnpm drizzle-kit studio # Visual DB browser at https://local.drizzle.studio
47
48# Testing
49pnpm test # vitest run
50pnpm test:watch # vitest (watch mode)
51pnpm test -- src/routes/users # Run tests in a specific directory
52
53# Linting & Formatting
54pnpm lint # eslint src/
55pnpm format # prettier --write src/
56pnpm typecheck # tsc --noEmit
57```
58
59## TypeScript Rules
60
61The `tsconfig.json` uses strict mode. Follow these without exception:
62
63- **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.
68
69```jsonc
70// tsconfig.json essentials — these should all be true
71{
72 "compilerOptions": {
73 "strict": true,
74 "noUncheckedIndexedAccess": true, // arr[0] is T | undefined
75 "exactOptionalPropertyTypes": true, // {a?: string} ≠ {a: string | undefined}
76 "noPropertyAccessFromIndexSignature": true
77 }
78}
79```
80
81## Fastify Patterns
82
83### Route Registration
84
85Routes are Fastify plugins. Register them with a prefix:
86
87```typescript
88// src/routes/users/index.ts
89import type { FastifyPluginAsync } from "fastify";
90import { listUsers, createUser } from "./handlers.js";
91import { CreateUserSchema, ListUsersSchema } from "./schemas.js";
92
93const usersRoutes: FastifyPluginAsync = async (fastify) => {
94 fastify.get("/", { schema: ListUsersSchema }, listUsers);
95 fastify.post("/", { schema: CreateUserSchema }, createUser);
96};
97
98export default usersRoutes;
99
100// src/server.ts
101app.register(usersRoutes, { prefix: "/api/users" });
102```
103
104### Handler Signature
105
106Always type the request with the schema generic — don't manually type `req.body`:
107
108```typescript
109import type { FastifyRequest, FastifyReply } from "fastify";
110import type { CreateUserBody } from "./schemas.js";
111
112export 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```
120
121**Do not** return the value from handlers. Use `reply.send()` explicitly — returning a value silently changes serialization behavior.
122
123### Schema Validation with TypeBox
124
125Fastify integrates with TypeBox natively. Define schemas next to routes:
126
127```typescript
128// src/routes/users/schemas.ts
129import { Type, type Static } from "@sinclair/typebox";
130
131const CreateUserBody = Type.Object({
132 email: Type.String({ format: "email" }),
133 name: Type.String({ minLength: 1, maxLength: 100 }),
134});
135type CreateUserBody = Static<typeof CreateUserBody>;
136
137export 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```
148
149<!-- If using Express/NestJS: use Zod instead of TypeBox. Example:
150```typescript
151import { 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. -->
160
161### Plugins (Decorators, Hooks)
162
163Wrap plugins with `fastify-plugin` when they should apply to the parent scope:
164
165```typescript
166import fp from "fastify-plugin";
167import type { FastifyPluginAsync } from "fastify";
168
169const 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};
180
181export default fp(authPlugin, { name: "auth" });
182```
183
184**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.
185
186## Database (Drizzle ORM)
187
188```typescript
189// src/db/schema.ts
190import { pgTable, uuid, varchar, timestamp } from "drizzle-orm/pg-core";
191
192export 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```
199
200```typescript
201// src/db/index.ts
202import { drizzle } from "drizzle-orm/node-postgres";
203import * as schema from "./schema.js";
204import { env } from "../lib/env.js";
205
206export const db = drizzle(env.DATABASE_URL, { schema });
207```
208
209- 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) => { ... })`.
212
213## Error Handling
214
215Define typed application errors. Do not throw raw `Error` objects with status codes:
216
217```typescript
218// src/lib/errors.ts
219export 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}
229
230export class NotFoundError extends AppError {
231 constructor(resource: string, id: string) {
232 super(404, `${resource} ${id} not found`, "NOT_FOUND");
233 }
234}
235
236export class ConflictError extends AppError {
237 constructor(message: string) {
238 super(409, message, "CONFLICT");
239 }
240}
241```
242
243```typescript
244// src/plugins/error-handler.ts — register this globally
245fastify.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 }
253
254 // Fastify validation errors have a .validation property
255 if (error.validation) {
256 reply.status(400).send({
257 error: "VALIDATION_ERROR",
258 message: error.message,
259 });
260 return;
261 }
262
263 // Unexpected errors — log full details, return generic message
264 request.log.error(error);
265 reply.status(500).send({
266 error: "INTERNAL_SERVER_ERROR",
267 message: "An unexpected error occurred",
268 });
269});
270```
271
272Services throw typed errors. Handlers don't catch them — the error handler plugin does.
273
274## Environment Config
275
276Load and validate env vars once at startup. Fail fast on missing config:
277
278```typescript
279// src/lib/env.ts
280import { Type, type Static } from "@sinclair/typebox";
281import { Value } from "@sinclair/typebox/value";
282
283const 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>;
290
291function 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 vars
299}
300
301export const env = loadEnv();
302```
303
304## Logging
305
306Fastify uses pino by default. Configure it at server creation:
307
308```typescript
309const app = Fastify({
310 logger: {
311 level: env.LOG_LEVEL ?? "info",
312 ...(env.NODE_ENV === "development" && {
313 transport: { target: "pino-pretty" },
314 }),
315 },
316});
317```
318
319- 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\`)`.
323
324## Testing
325
326Use vitest + light-my-request (built into Fastify):
327
328```typescript
329// src/routes/users/__tests__/handlers.test.ts
330import { describe, it, expect, beforeAll, afterAll } from "vitest";
331import { buildApp } from "../../../server.js";
332
333describe("POST /api/users", () => {
334 let app: Awaited<ReturnType<typeof buildApp>>;
335
336 beforeAll(async () => {
337 app = await buildApp(); // Returns configured Fastify instance without calling .listen()
338 });
339
340 afterAll(async () => {
341 await app.close();
342 });
343
344 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 });
350
351 expect(response.statusCode).toBe(201);
352 expect(response.json()).toMatchObject({
353 email: "test@example.com",
354 name: "Test User",
355 });
356 });
357
358 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 });
364
365 expect(response.statusCode).toBe(400);
366 });
367});
368```
369
370For 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.
371
372## Anti-Patterns — Do Not
373
374- **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.
383
384## Import Conventions
385
386```typescript
387import { randomUUID } from "node:crypto"; // 1. Node built-ins (always use node: prefix)
388import Fastify from "fastify"; // 2. External packages
389import { db } from "@/db/index.js"; // 3. Internal absolute (path aliases)
390import { CreateUserSchema } from "./schemas.js"; // 4. Internal relative
391```
392
393Use `.js` extensions in all imports. TypeScript with `"moduleResolution": "nodenext"` requires them.
394