Node.js with TypeScript
Build Express APIs with TypeScript: typed middleware, request/response types, typed environment variables, and project setup.
Project Setup
TypeScript works with Node.js by compiling .ts files to .js before execution. You need @types/node for Node.js globals like process and Buffer, and ts-node-dev for development — it watches your files and restarts the server on changes without a separate compile step.
npm install express
npm install -D typescript @types/node @types/express ts-node-dev
npx tsc --init
tsconfig.json for Node.js:
{
"compilerOptions": {
"target": "ES2022", // modern Node.js supports ES2022 natively
"module": "CommonJS", // Node.js uses CommonJS by default
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true, // allows default imports from CommonJS modules
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true // maps compiled JS errors back to .ts line numbers
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Typed Environment Variables
process.env values are string | undefined — TypeScript won’t let you use them as string or number without handling the undefined case. More importantly, scattered process.env access throughout a codebase makes it impossible to see at a glance what configuration the app needs. A centralized config module validates every variable at startup (failing loudly if required values are missing) and exports them as specific types that the rest of the app can use safely.
// src/config/env.ts
function requireEnv(name: string): string {
const value = process.env[name];
// Fail at startup rather than failing mysteriously at runtime
if (!value) throw new Error(`Environment variable ${name} is required`);
return value;
}
function optionalEnv(name: string, fallback: string): string {
return process.env[name] ?? fallback;
}
export const env = {
NODE_ENV: optionalEnv("NODE_ENV", "development") as "development" | "production" | "test",
PORT: parseInt(optionalEnv("PORT", "3000"), 10), // typed as number, not string
DATABASE_URL: requireEnv("DATABASE_URL"), // throws if missing
JWT_SECRET: requireEnv("JWT_SECRET"), // throws if missing
JWT_EXPIRY: optionalEnv("JWT_EXPIRY", "7d"),
CORS_ORIGIN: optionalEnv("CORS_ORIGIN", "http://localhost:5173"),
} as const;
Typed Express Application
Separating app creation from server startup is a common pattern that makes the app easier to test — you can import createApp() in tests without starting a real server. TypeScript types the Application return value, which means calling code gets full autocomplete on the app’s methods.
// src/app.ts
import express, { Application } from "express";
import { userRouter } from "./routes/users";
import { errorHandler } from "./middleware/errorHandler";
import { env } from "./config/env";
// Factory function — returns a configured app without listening
export function createApp(): Application {
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.use("/api/users", userRouter);
// Error handler must be registered last
app.use(errorHandler);
return app;
}
// src/server.ts — the only file that starts listening
import { createApp } from "./app";
import { env } from "./config/env";
const app = createApp();
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT} (${env.NODE_ENV})`);
});
Typed Request and Response
Express’s generic types Request<Params, ResBody, ReqBody, Query> let you type each part of an HTTP request independently. Without these, req.params.id is string (which is correct), but req.body is any — a hole in your type coverage. Defining request types at the route level means the handler body is fully typed and TypeScript will catch mismatches between the route definition and its implementation.
import { Request, Response, NextFunction } from "express";
// Named request types make route handlers more readable
type GetUserRequest = Request<{ id: string }>;
type CreateUserRequest = Request<{}, {}, CreateUserDto>;
type ListUsersRequest = Request<{}, {}, {}, { page?: string; limit?: string }>;
// src/routes/users.ts
import { Router } from "express";
export const userRouter = Router();
userRouter.get(
"/:id",
async (req: GetUserRequest, res: Response<User | { error: string }>) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ error: "Invalid user ID" });
}
const user = await userService.findById(id);
if (!user) return res.status(404).json({ error: "User not found" });
return res.json(user);
}
);
userRouter.post(
"/",
async (req: CreateUserRequest, res: Response<User | { error: string }>) => {
const dto = req.body; // typed as CreateUserDto — not any
const user = await userService.create(dto);
return res.status(201).json(user);
}
);
userRouter.get(
"/",
async (req: ListUsersRequest, res: Response<PaginatedResponse<User>>) => {
const page = parseInt(req.query.page ?? "1", 10);
const limit = parseInt(req.query.limit ?? "20", 10);
const result = await userService.list(page, limit);
return res.json(result);
}
);
Typed Middleware
Middleware that attaches data to req — like an authenticated user or a request ID — needs to be reflected in Express’s Request type so that downstream handlers know those properties exist. Module augmentation with declare global extends Express’s interface without forking its types. The RequestHandler type ensures middleware functions match the expected signature.
import { Request, Response, NextFunction, RequestHandler } from "express";
// Extend Express's Request type to include custom properties
declare global {
namespace Express {
interface Request {
user?: AuthUser; // set by authenticate middleware
requestId: string; // set by requestId middleware
}
}
}
// Attaches a unique ID to every request — useful for log correlation
export const requestId: RequestHandler = (req, _res, next) => {
req.requestId = crypto.randomUUID();
next();
};
// Validates the Bearer token and attaches the decoded user to the request
export const authenticate: RequestHandler = async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing token" });
}
const token = authHeader.slice(7);
try {
req.user = await verifyJwt(token); // attaches to req — available in all subsequent handlers
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
};
// Factory that returns a middleware enforcing one of the given roles
export function requireRole(...roles: string[]): RequestHandler {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
const hasRole = roles.some((r) => req.user!.roles.includes(r));
if (!hasRole) {
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
// Usage — middleware chain reads as a clear description of the security requirements
userRouter.delete(
"/:id",
authenticate,
requireRole("admin"),
async (req, res) => {
await userService.delete(parseInt(req.params.id, 10));
res.status(204).send();
}
);
Typed Error Handler
Express error handlers have a 4-parameter signature (err, req, res, next) — if you write only 3 parameters, Express won’t recognize it as an error handler. TypeScript’s ErrorRequestHandler type enforces this signature and also types err as unknown, which forces you to check its type before using it. Combined with a typed error class hierarchy, this gives you clean, exhaustive error routing.
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction, ErrorRequestHandler } from "express";
import { AppError, NotFoundError, ValidationError } from "../errors";
// ErrorRequestHandler types all 4 parameters correctly — required for Express to use this as an error handler
export const errorHandler: ErrorRequestHandler = (
err: unknown,
_req: Request,
res: Response,
_next: NextFunction
) => {
if (err instanceof ValidationError) {
return res.status(422).json({
error: err.message,
code: err.code,
fields: err.fields, // only available on ValidationError
});
}
if (err instanceof NotFoundError) {
return res.status(404).json({
error: err.message,
code: err.code,
});
}
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
});
}
// Unknown error — log it but don't expose internal details to the client
console.error("Unhandled error:", err);
return res.status(500).json({
error: "Internal server error",
code: "INTERNAL_ERROR",
});
};
Typed Service Layer
The service layer is where business logic lives, isolated from HTTP concerns. Typing the DTOs (Data Transfer Objects) that cross the layer boundary enforces a contract between the route handlers and the service — if a route handler tries to call userService.create() without the required fields, TypeScript catches it at compile time rather than at runtime in production.
// src/services/userService.ts
interface CreateUserDto {
name: string;
email: string;
password: string;
role?: "admin" | "user";
}
interface UpdateUserDto {
name?: string;
email?: string;
role?: "admin" | "user";
}
class UserService {
constructor(private readonly db: Database) {}
async findById(id: number): Promise<User | null> {
const row = await this.db.query<UserRow>(
"SELECT * FROM users WHERE id = $1",
[id]
);
return row ? this.toUser(row) : null;
}
async create(dto: CreateUserDto): Promise<User> {
const hashedPassword = await bcrypt.hash(dto.password, 10);
const row = await this.db.queryOne<UserRow>(
"INSERT INTO users (name, email, password, role) VALUES ($1, $2, $3, $4) RETURNING *",
[dto.name, dto.email, hashedPassword, dto.role ?? "user"]
);
return this.toUser(row);
}
async update(id: number, dto: UpdateUserDto): Promise<User | null> {
const user = await this.findById(id);
if (!user) return null;
// ... update logic
return user;
}
// Strip the password before returning — TypeScript ensures the return type matches User, not UserRow
private toUser(row: UserRow): User {
const { password: _, ...user } = row;
return user;
}
}
Validation with Zod
Runtime validation is essential for any data that crosses a trust boundary — HTTP request bodies, query parameters, external API responses. Zod is TypeScript-first: you define a schema and get a TypeScript type inferred from it for free, so your runtime validation and compile-time types always stay in sync. This eliminates the common bug of having a TypeScript interface and a separate validation schema that drift apart over time.
npm install zod
import { z } from "zod";
// Define the schema once — the TypeScript type is derived from it automatically
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(["admin", "user"]).default("user"),
});
// No separate interface needed — the type comes from the schema
type CreateUserDto = z.infer<typeof CreateUserSchema>;
// { name: string; email: string; password: string; role: "admin" | "user" }
// Reusable validation middleware factory — pass any Zod schema
function validate(schema: z.ZodSchema): RequestHandler {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: "Validation failed",
fields: result.error.flatten().fieldErrors,
});
}
req.body = result.data; // replace with parsed/coerced data
next();
};
}
// Usage — req.body is fully typed as CreateUserDto after the validate middleware runs
userRouter.post("/", validate(CreateUserSchema), async (req, res) => {
const dto: CreateUserDto = req.body;
const user = await userService.create(dto);
res.status(201).json(user);
});