Skip to main content
TypeScript intermediate Lesson 19 of 21

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);
});

Frequently Asked Questions

Do I need @types/node and @types/express?
Yes. Node.js and Express are JavaScript libraries without built-in TypeScript types. Install @types/node for Node.js globals (process, Buffer, etc.) and @types/express for Express types.
How do I type request.body in Express?
Express types req.body as any by default. Use generics: Request<Params, ResBody, ReqBody, Query> to type it. For example, Request<{}, {}, CreateUserDto> gives you a typed body.
How do I type environment variables?
Process.env values are string | undefined. Create a typed config module that reads, validates, and exports environment variables as specific types. Never access process.env.ANYTHING directly in application code.