ChatGPT Image Aug 11, 2025, 03_57_57 PM

A Fast, Opinionated Node + Express + TypeScript Boilerplate (with real-world error handling)

I built a lightweight but opinionated Express + TypeScript starter that lets me spin up APIs in minutes without re-doing the boring parts. It ships with strict typing, a clean folder layout, a predictable error model, production-ready middleware, and batteries-included testing so you can focus on your domain logic.

GitHub: https://github.com/kalemmentore868/boilerplate-node-ts-api


Why another boilerplate?

Most starters either feel too bare (you still copy/paste half your stack) or too heavy (framework on top of framework). This one aims for the sweet spot:

  • TypeScript-first with strict types and path aliases
  • Predictable errors from day one (details below)
  • Sane security & DX middleware (CORS, Helmet, rate limiting, logging)
  • Testing that mirrors production (request-level tests with Supertest)

The error model: explicit, typed, and boring-on-purpose

Errors should be boring. The boilerplate uses a tiny class hierarchy so every failure you throw maps to a clear HTTP response:

class ExpressError extends Error {
  status;
  constructor(message: string, status: number) {
    super();
    this.message = message;
    this.status = status;
  }
}

class NotFoundError extends ExpressError {
  constructor(message = "Not Found") { super(message, 404); }
}
class UnauthorizedError extends ExpressError {
  constructor(message = "Unauthorized") { super(message, 401); }
}
class BadRequestError extends ExpressError {
  constructor(message = "Bad Request") { super(message, 400); }
}
class ForbiddenError extends ExpressError {
  constructor(message = "Forbidden Request") { super(message, 403); }
}
class InternalServerError extends ExpressError {
  constructor(message = "Internal Server Error") { super(message, 500); }
}

What’s unique here?

  1. One source of truth for status codes.
    You throw domain-friendly classes (new BadRequestError("Email required")) and never repeat the status in handlers.
  2. Consistent JSON shape.
    The global error middleware serializes every error to the same response structure—great for clients and logging.
  3. Type-narrowing friendly.
    Since everything extends ExpressError, the handler can confidently read err.status without guessing.

Global error handler:

import type { ErrorRequestHandler } from "express";
import { ExpressError, InternalServerError } from "./errors";

export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
  if (err instanceof ExpressError) {
    return res.status(err.status).json({
      error: { message: err.message, status: err.status },
    });
  }
  const fallback = new InternalServerError();
  return res.status(fallback.status).json({
    error: { message: fallback.message, status: fallback.status },
  });
};

Use in routes:

app.get("/api/users/:id", async (req, res) => {
  const user = await repo.find(req.params.id);
  if (!user) throw new NotFoundError("User not found");
  res.json({ data: user });
});

Result: fewer if/else branches, zero duplicate status codes, cleaner controllers.


Middleware: opinionated defaults you can keep or swap

  • Security: helmet() by default, sensible CORS configuration, and express-rate-limit to dampen abuse.
  • Request tracing: a tiny requestId middleware + morgan log format that includes the ID so logs and client issues correlate.
  • Validation helper (Zod friendly): a validate(schema) middleware you can drop into routes.

Example validator:

import { z } from "zod";
import type { RequestHandler } from "express";

export const validate =
  (schema: z.ZodSchema): RequestHandler =>
  (req, _res, next) => {
    const result = schema.safeParse({
      body: req.body,
      query: req.query,
      params: req.params,
    });
    if (!result.success)
      return next(new BadRequestError(result.error.issues[0]?.message ?? "Invalid request"));
    next();
  };

Testing setup: API-first, not unit-only

I prefer to test APIs the way they’re used in the wild—through HTTP. The boilerplate includes:

  • Jest + Supertest for request-level tests
  • TypeScript support via a transformer (e.g., ts-jest/swc-jest)
  • Isolated env: .env.test loaded for tests so you can point to a test DB or in-memory store
  • Lightweight factories (optional) for creating test data

Example test:

import request from "supertest";
import { app } from "../app";

describe("Auth", () => {
  it("returns 401 on bad credentials", async () => {
    const res = await request(app)
      .post("/api/auth/login")
      .send({ email: "x@y.z", password: "nope" });
    expect(res.status).toBe(401);
    expect(res.body.error.message).toMatch(/unauthorized/i);
  });
});

Because the error shape is consistent, assertions are trivial and stable over time.


Folder structure (brief)

src/
  app.ts            # Express app
  server.ts         # boot (separate from app to aid testing)
  errors/           # error classes
  middleware/       # errorHandler, requestId, validate, rateLimit, etc.
  routes/           # modular routers
  services/         # business logic
  tests/            # request-level tests

Quick start

git clone https://github.com/YOUR-USERNAME/express-ts-boilerplate
cd express-ts-boilerplate
pnpm i        # or npm/yarn
cp .env.example .env
pnpm dev      # nodemon + ts-node (or tsx) for DX
pnpm test     # jest + supertest
pnpm build    # compile to dist
pnpm start    # run compiled server

What’s “opinionated” here?

  • Errors as classes, not strings — fewer branches, better intent.
  • Request-level tests first — guard contracts, not just internals.
  • Security by default — Helmet/CORS/rate-limits ship enabled.
  • Small, composable middlewares — easy to swap, easy to delete.
  • Minimal dependencies — no mega-framework; just Express + TS.

Roadmap

  • CLI to scaffold modules (route + service + test)
  • Example auth flow (JWT) behind the error model
  • Pluggable logging transports (pretty vs. JSON)

If you want a TypeScript API starter that’s fast to reason about and boring in all the right places, start here and tweak to taste.

Grab the code: https://github.com/kalemmentore868/boilerplate-node-ts-api