Node.js + Express + TypeScript
Project context
This is a Node.js HTTP service built with Express and TypeScript. We layer the code (routes → controllers → services → repositories), validate at boundaries with Zod, and treat errors as values where it makes the contract clearer.
Stack
- Node.js 20+ (LTS)
- Express 4.x (or 5.x once stable)
- TypeScript 5+ in strict mode
- Zod for validation
- Drizzle or Prisma for DB access
- pino for structured logging
- vitest for tests, supertest for HTTP integration tests
- pnpm
Folder structure
src/
index.ts — entrypoint: bootstrap + listen
app.ts — Express app factory (no listen)
config.ts — env config (read once, validated with Zod)
routes/ — route definitions, one file per resource
controllers/ — HTTP request → service call → response
services/ — business logic, no Express types
repos/ — DB queries, no business logic
middleware/ — auth, logging, error handler
schemas/ — Zod schemas for request/response shapes
errors/ — typed error classes
lib/ — utilities
tests/
integration/ — supertest against app
unit/ — pure logic tests
The app factory in app.ts returns the configured Express instance without listening — makes integration tests easy.
Config
- One
config.tsat boot that readsprocess.env, parses with Zod, and exports a typed object - Fail fast on missing or malformed env vars
- Never read
process.envdirectly elsewhere
import { z } from 'zod'
const Schema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
})
export const config = Schema.parse(process.env)
Validation at boundaries
Validate every external input at its boundary:
const CreatePost = z.object({
title: z.string().min(1).max(200),
body: z.string().max(10_000),
})
router.post('/posts', async (req, res, next) => {
try {
const data = CreatePost.parse(req.body)
const post = await postService.create(data)
res.status(201).json(post)
} catch (err) {
next(err)
}
})
Internal modules trust their inputs; only validate at HTTP boundaries, queue consumers, and external callbacks.
Error handling
- One Express error-handling middleware at the bottom of the chain
- Throw typed errors (
NotFoundError,ValidationError, etc.) from services - Translate them to HTTP statuses in the error middleware
- Always wrap async handlers — Express 4 doesn't catch promise rejections by default. Either
try/catch + next(err)or useexpress-async-errors.
class NotFoundError extends Error {
status = 404
constructor(message: string) { super(message) }
}
Logging
- pino: structured JSON, fast
- One log per request via
pino-httpmiddleware - Don't log secrets / tokens / PII
- Use child loggers (
logger.child({ requestId })) to attach context
Database
- One repo per resource (
postRepo.findById,postRepo.create) - Repos return plain objects, not framework types
- Services compose repos and call external APIs
- Wrap multi-step writes in a transaction
Async patterns
async/awaiteverywhere — never raw Promises with.then()chainsPromise.allfor parallel I/O;Promise.allSettledwhen partial failure is acceptable- Cancel hanging requests with
AbortController— passsignaltofetchand DB clients
Graceful shutdown
const server = app.listen(config.PORT)
const shutdown = async () => {
server.close()
await db.disconnect()
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
Never let the process exit with active DB connections — leaks pool slots and may corrupt in-flight transactions.
Patterns to avoid
req.bodywithout validation — always Zod-parse- Throwing strings or plain objects — always real
Errorinstances process.envoutsideconfig.ts— nowhere else- Mixing
async/awaitand.then()in the same module - Sync DB calls (
fs.readFileSyncin a request handler) - Default export of the Express app from
app.ts— named exports for grep-ability
Testing
- Integration tests with supertest hitting
app.ts - Unit tests for service-layer logic, no Express mocks needed
- One test DB per process — wipe between tests via transactions or
TRUNCATE - Mock external APIs at the HTTP layer (nock, msw); never mock your own modules
Tooling
pnpm dev—tsx watch src/index.tspnpm build—tscpnpm start—node dist/index.jspnpm test— vitestpnpm lint— ESLintpnpm typecheck—tsc --noEmit
AI behavioral rules
- Validate every external input with Zod at the HTTP boundary; never
req.body as any - Throw real
Errorsubclasses; never strings - Read
process.envonly insideconfig.ts - Never call
next()andreturn res.json(...)in the same path — pick one - Use
Promise.allfor independent I/O; don't sequentially await unrelated calls - Set
AbortControllertimeouts on every outboundfetch - Run lint, typecheck, and tests before declaring a task done