6. TypeScript with Node & Express
1. Project Setup
npm i -D typescript tsx @types/node @types/express
npx tsc --init// tsconfig.json for a Node backend
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}// package.json
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
}
}Which Runner?
| Tool | Notes |
|---|---|
tsx | Fast, esbuild-based, the current default choice for dev |
ts-node | Older, slower, does full type checking |
node --experimental-strip-types | Node 22+ built-in, strips types without checking |
| Native (Node 23.6+) | Runs .ts directly, still strips rather than checks |
Interview Point
All of these strip types rather than check them at runtime. Keep a separate tsc --noEmit step in CI — a fast runner that skips type checking will happily run broken code.
2. Typing Express
import express, { Request, Response, NextFunction } from "express";
const app = express();
app.use(express.json());
app.get("/health", (req: Request, res: Response) => {
res.json({ status: "ok" });
});Typed Params, Body and Query
Request is generic: Request<Params, ResBody, ReqBody, Query>.
interface UserParams { id: string } // params are ALWAYS strings
interface CreateUserBody { name: string; email: string }
interface ListQuery { page?: string; sort?: string }
app.get(
"/users/:id",
async (req: Request<UserParams>, res: Response) => {
const user = await getUser(req.params.id); // typed
res.json(user);
}
);
app.post(
"/users",
async (req: Request<{}, {}, CreateUserBody>, res: Response) => {
const { name, email } = req.body; // typed
res.status(201).json(await createUser({ name, email }));
}
);
app.get(
"/users",
async (req: Request<{}, {}, {}, ListQuery>, res: Response) => {
const page = Number(req.query.page ?? 1);
}
);The Honest Caveat
These generics only describe the shape. Express does not validate anything. req.body typed as CreateUserBody is a claim, not a guarantee — the client can send whatever it wants.
3. Validate, Then Type
The correct pattern: derive the type from a runtime schema.
import { z } from "zod";
const createUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
type CreateUserDto = z.infer<typeof createUserSchema>;
app.post("/users", async (req, res) => {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: "Validation failed",
issues: parsed.error.flatten().fieldErrors,
});
}
const user = await createUser(parsed.data); // parsed.data is CreateUserDto
res.status(201).json(user);
});As Reusable Middleware
import { ZodSchema } from "zod";
function validate<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ issues: parsed.error.flatten() });
}
req.body = parsed.data; // replaced with the parsed, coerced value
next();
};
}
app.post("/users", validate(createUserSchema), createUserHandler);4. Extending the Request Type
Every auth middleware needs this. It is a very common interview question.
// src/types/express.d.ts
import { User } from "../models/user";
declare global {
namespace Express {
interface Request {
user?: User;
}
}
}
export {}; // makes this file a module — requiredconst requireAuth = async (req: Request, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Unauthorized" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = await getUser(payload.sub); // now type-safe
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
};This uses declaration merging, which is why interface matters — a type cannot be merged like this.
Alternative Without Globals
interface AuthedRequest extends Request {
user: User; // required, not optional, after the middleware
}
const handler = (req: AuthedRequest, res: Response) => {
req.user.id; // no optional chaining needed
};Cleaner typing, but you must cast at the route registration. Both approaches appear in real codebases.
5. Async Handler Error Handling
Express 4 does not catch errors from async handlers — an unhandled rejection hangs the request.
// ❌ Error is swallowed, request hangs
app.get("/users", async (req, res) => {
const users = await db.query(); // throws → nothing catches it
res.json(users);
});
// ✅ Wrapper
type AsyncHandler = (
req: Request,
res: Response,
next: NextFunction
) => Promise<unknown>;
const asyncHandler =
(fn: AsyncHandler) =>
(req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get("/users", asyncHandler(async (req, res) => {
res.json(await db.query());
}));Express 5 fixed this — it forwards rejected promises to the error handler automatically. Worth knowing which version you're on.
6. Typed Error Handling
export class AppError extends Error {
constructor(
message: string,
public statusCode: number = 500,
public isOperational: boolean = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype); // required for instanceof
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404);
}
}// Error middleware — MUST have four parameters or Express won't recognise it
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({ error: err.message });
}
console.error("Unexpected error:", err);
res.status(500).json({ error: "Internal server error" });
});Two Details Interviewers Check
Object.setPrototypeOf— without it,instanceof AppErrorfails when compiling to ES5 targets- The error middleware needs exactly four parameters. Three and Express treats it as normal middleware
catch Is unknown
Under strict (specifically useUnknownInCatchVariables):
try {
await risky();
} catch (error) {
// error is unknown, not any
if (error instanceof Error) {
console.error(error.message);
} else {
console.error("Unknown error", error);
}
}You cannot throw only Error objects in JavaScript — throw "string" is legal — so unknown is correct.
7. Environment Variables
process.env values are all string | undefined. Validate them once at startup.
// src/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
REDIS_URL: z.string().url().optional(),
});
export const env = envSchema.parse(process.env);import { env } from "./env";
app.listen(env.PORT); // number, not string
const secret = env.JWT_SECRET; // string, guaranteed presentWhy This Matters
Without it, a missing JWT_SECRET fails at 3am when the first user logs in. With it, the process refuses to start and the deploy fails immediately. Fail fast at startup is the phrase to use.
8. Typing Database Access
Prisma — Types Generated From the Schema
import { PrismaClient, Prisma } from "@prisma/client";
const prisma = new PrismaClient();
const user = await prisma.user.findUnique({
where: { id: 1 },
include: { posts: true },
});
// Fully typed, including the posts relation
// Derive a type from a query shape
type UserWithPosts = Prisma.UserGetPayload<{ include: { posts: true } }>;Deriving From a Function
async function getUserWithPosts(id: number) {
return prisma.user.findUnique({ where: { id }, include: { posts: true } });
}
type Result = Awaited<ReturnType<typeof getUserWithPosts>>;
// User & { posts: Post[] } | nullTypes that cannot drift from the query.
9. Project Structure
src/
├── index.ts app entry
├── env.ts validated config
├── routes/
│ └── users.route.ts
├── controllers/
│ └── users.controller.ts
├── services/
│ └── users.service.ts business logic, no Express types
├── middleware/
│ ├── auth.ts
│ ├── validate.ts
│ └── error.ts
├── schemas/
│ └── user.schema.ts Zod schemas + inferred types
├── types/
│ └── express.d.ts
└── utils/The Rule Worth Stating
Services must not import Express types. A service takes plain arguments and returns plain data, so it can be called from a route, a cron job, a queue worker or a test without a fake req/res. Interviewers notice this.
// ❌ Coupled to HTTP
async function createUser(req: Request) { … }
// ✅ Reusable
async function createUser(data: CreateUserDto): Promise<User> { … }10. Common Node TypeScript Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module 'express' | Missing types | npm i -D @types/express |
Cannot find name 'process' | Missing Node types | npm i -D @types/node |
ERR_MODULE_NOT_FOUND for a relative import | ESM needs file extensions | Import ./user.js even from .ts |
exports is not defined | Module mismatch | Align type in package.json with module in tsconfig |
req.user does not exist | Missing declaration merging | Add types/express.d.ts |
Object is possibly 'undefined' on process.env.X | Correct — it may be unset | Validate with Zod at startup |
error is of type 'unknown' | useUnknownInCatchVariables | Narrow with instanceof Error |
The ESM Extension Gotcha
With "module": "NodeNext" and "type": "module", relative imports need a .js extension even in TypeScript files:
import { getUser } from "./services/user.js"; // yes, .js — not .tsThis confuses everyone the first time. TypeScript is describing the path of the emitted file.
11. Building for Production
npm run typecheck # tsc --noEmit — the real check
npm run build # tsc — emit to dist/
npm start # node dist/index.jsDockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]Multi-stage: TypeScript and dev dependencies never reach the final image. npm ci --omit=dev in the runner keeps it small. USER node avoids running as root.
Interview Point
Type checking is a build-time gate, not a runtime feature. In CI, run tsc --noEmit as a separate step from the bundle. A fast transpiler like esbuild or SWC will happily emit code that does not type-check — the check has to be explicit.