Skip to content

6. TypeScript with Node & Express


1. Project Setup

bash
npm i -D typescript tsx @types/node @types/express
npx tsc --init
json
// 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"]
}
json
// package.json
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "typecheck": "tsc --noEmit"
  }
}

Which Runner?

ToolNotes
tsxFast, esbuild-based, the current default choice for dev
ts-nodeOlder, slower, does full type checking
node --experimental-strip-typesNode 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

ts
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>.

ts
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.

ts
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

ts
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.

ts
// src/types/express.d.ts
import { User } from "../models/user";

declare global {
  namespace Express {
    interface Request {
      user?: User;
    }
  }
}

export {};   // makes this file a module — required
ts
const 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

ts
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.

ts
// ❌ 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

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

  1. Object.setPrototypeOf — without it, instanceof AppError fails when compiling to ES5 targets
  2. The error middleware needs exactly four parameters. Three and Express treats it as normal middleware

catch Is unknown

Under strict (specifically useUnknownInCatchVariables):

ts
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.

ts
// 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);
ts
import { env } from "./env";

app.listen(env.PORT);           // number, not string
const secret = env.JWT_SECRET;  // string, guaranteed present

Why 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

ts
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

ts
async function getUserWithPosts(id: number) {
  return prisma.user.findUnique({ where: { id }, include: { posts: true } });
}

type Result = Awaited<ReturnType<typeof getUserWithPosts>>;
// User & { posts: Post[] } | null

Types 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.

ts
// ❌ Coupled to HTTP
async function createUser(req: Request) { … }

// ✅ Reusable
async function createUser(data: CreateUserDto): Promise<User> { … }

10. Common Node TypeScript Errors

ErrorCauseFix
Cannot find module 'express'Missing typesnpm i -D @types/express
Cannot find name 'process'Missing Node typesnpm i -D @types/node
ERR_MODULE_NOT_FOUND for a relative importESM needs file extensionsImport ./user.js even from .ts
exports is not definedModule mismatchAlign type in package.json with module in tsconfig
req.user does not existMissing declaration mergingAdd types/express.d.ts
Object is possibly 'undefined' on process.env.XCorrect — it may be unsetValidate with Zod at startup
error is of type 'unknown'useUnknownInCatchVariablesNarrow with instanceof Error

The ESM Extension Gotcha

With "module": "NodeNext" and "type": "module", relative imports need a .js extension even in TypeScript files:

ts
import { getUser } from "./services/user.js";   // yes, .js — not .ts

This confuses everyone the first time. TypeScript is describing the path of the emitted file.


11. Building for Production

bash
npm run typecheck    # tsc --noEmit — the real check
npm run build        # tsc — emit to dist/
npm start            # node dist/index.js

Dockerfile

dockerfile
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.

© 2025 DDocs · Dipak's Documentation Guide