TypeScript / Node.js Backend Development
When to Use
- Writing or reviewing TypeScript / Node.js web services / REST APIs / async workers.
- Pairs with typescript.instructions.md.
- For React/TSX frontend work see dev-react-frontend-development.
- Collaboration protocol: see ops-agent-collaboration.
Golden Rules
- Validate at the boundary, trust the inside: Zod schemas on every request body / query / header; service layer takes typed values only.
- Async end-to-end:
async/awaitthrough the stack; every Promise isawaited or has an explicit.catch; no callback hell. - Dependency injection over global singletons: pass dependencies as constructor args or via a DI container; no module-level singletons.
- One transaction per request (or per use-case); never auto-commit in handlers.
- Domain errors are typed: a small class hierarchy mapped at the framework boundary to HTTP status codes.
- Configuration via typed env parsing:
zodorenvalidfor typed env vars; exit immediately on validation failure. - Observability default-on: structured logging (pino/winston), correlation IDs (
requestId/traceId), request timing middleware. - No N+1: explicit eager loading in ORM queries; add a debug-time query counter.
- Bounded concurrency on outbound calls: timeouts via
AbortController, retries with jitter for transient failures only. - Migrations are versioned: Prisma migrate / Drizzle Kit; one head per branch; reviewed alongside the code that needs them.
Recipes
Recipe A: Express handler with Zod + typed error
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
const CreateOrderSchema = z.object({
userId: z.string().min(1),
amountCents: z.number().int().positive(),
});
type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
const router = Router();
router.post('/orders', async (req: Request, res: Response, next: NextFunction) => {
try {
const input = CreateOrderSchema.parse(req.body);
const order = await orderService.create(input);
res.status(201).json(order);
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: err.errors });
} else if (err instanceof UserNotFoundError) {
res.status(404).json({ error: err.message });
} else {
next(err);
}
}
});
Recipe B: Prisma transaction per request
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function createOrder(input: CreateOrderInput): Promise<Order> {
return prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: input.userId } });
if (!user) throw new UserNotFoundError(input.userId);
return tx.order.create({
data: { userId: input.userId, amountCents: input.amountCents },
});
});
}
Recipe C: Typed environment config
import { z } from 'zod';
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(2000),
});
const env = EnvSchema.parse(process.env); // exits on validation failure
Recipe D: Outbound call with AbortController timeout
import fetch from 'node-fetch';
async function callExternalAPI(url: string, timeoutMs: number = 2000): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
Recipe E: Structured logging middleware
import pino from 'pino';
import { randomUUID } from 'crypto';
const logger = pino({ level: env.LOG_LEVEL });
app.use((req, res, next) => {
const requestId = req.headers['x-request-id'] as string || randomUUID();
req.headers['x-request-id'] = requestId;
const childLogger = logger.child({ requestId });
req.log = childLogger;
const start = Date.now();
res.on('finish', () => {
childLogger.info({
method: req.method,
url: req.url,
statusCode: res.statusCode,
latencyMs: Date.now() - start,
});
});
next();
});
Anti-patterns
[BAD] Mixing ESM and CJS
Fix: pick one module system in tsconfig.json and package.json; do not mix.
[BAD] Using any or as to bypass type checks
Fix: use proper Zod runtime validation at boundaries; use discriminated unions internally.
[BAD] Calling console.log in production
Fix: use the project's unified logging facade (pino/winston).
[BAD] Global module-level singletons
Fix: inject dependencies via constructor or DI container.
[BAD] Silent catch blocks
Fix: after catching, decide: convert, log and continue, or rethrow.
[BAD] Unbounded fetch / axios calls without timeout
Fix: always use AbortController or equivalent.
Review Checklist
- Is
strictenabled in tsconfig? - Are all route handlers validated with Zod at the boundary?
- Is
async/awaitused end-to-end? - Are domain errors typed and mapped to HTTP status codes?
- Is structured logging used instead of
console.log? - Are outbound calls bounded by timeouts?
- Are database queries free of N+1?
- Is configuration validated at startup?
Source: OriPoin/AgentOrch — distributed by TomeVault.