Back to blog

TypeScript: Build REST API with Express.js

typescriptexpressrest-apibackendnodejs
TypeScript: Build REST API with Express.js

In the previous post, we set up a production-ready TypeScript project. Now it's time to build something real — a complete REST API with Express.js that uses every tool we configured.

This isn't a toy CRUD example. We'll build a task management API with proper validation, error handling, layered architecture, and API documentation. The kind of API you'd ship to production.

Time commitment: 2-3 hours
Prerequisites: TypeScript: Setup Project for Node.js Backend

What You'll Learn

✅ Express.js with TypeScript — type-safe routes, middleware, and error handling
✅ Request validation with Zod — schemas that validate at runtime and infer types
✅ Typed request/response — no more any in your handlers
✅ Middleware patterns — auth, logging, request ID, rate limiting
✅ Global error handling — centralized, consistent error responses
✅ Controller/Service/Repository — clean separation of concerns
✅ API versioning — future-proof your endpoints
✅ OpenAPI/Swagger docs — auto-generated from your code


1. Project Setup

Start from the setup we built in Post 1, then add Express and its types:

npm install express zod
npm install -D @types/express

Also install utilities we'll use throughout:

npm install uuid
npm install -D @types/uuid

Your project structure will look like this when we're done:

src/
├── index.ts                    # Entry point
├── app.ts                      # Express app setup
├── config/
│   └── env.ts                  # Environment validation (from Post 1)
├── routes/
│   ├── index.ts                # Route aggregator
│   └── task.routes.ts          # Task endpoints
├── controllers/
│   └── task.controller.ts      # HTTP layer
├── services/
│   └── task.service.ts         # Business logic
├── repositories/
│   └── task.repository.ts      # Data access (in-memory for now)
├── middleware/
│   ├── error-handler.ts        # Global error handler
│   ├── validate.ts             # Zod validation middleware
│   ├── request-id.ts           # Request ID middleware
│   └── not-found.ts            # 404 handler
├── schemas/
│   └── task.schema.ts          # Zod schemas
├── types/
│   └── index.ts                # Shared types
└── utils/
    └── errors.ts               # Custom error classes

2. Custom Error Classes

Before writing any routes, define how errors work. This is the foundation of consistent API responses.

// src/utils/errors.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string,
  ) {
    super(message);
    this.name = "AppError";
  }
}
 
export class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(404, `${resource} with id '${id}' not found`, "NOT_FOUND");
    this.name = "NotFoundError";
  }
}
 
export class ValidationError extends AppError {
  constructor(
    message: string,
    public details?: Record<string, string[]>,
  ) {
    super(400, message, "VALIDATION_ERROR");
    this.name = "ValidationError";
  }
}
 
export class ConflictError extends AppError {
  constructor(message: string) {
    super(409, message, "CONFLICT");
    this.name = "ConflictError";
  }
}

Why custom error classes? Because throw new NotFoundError("Task", id) is infinitely clearer than throw { status: 404, message: "..." }. And the error handler knows exactly what to do with each type.


3. Zod Schemas — Validation + Types in One

Zod lets you define a schema once and get both runtime validation and TypeScript types. No duplication.

// src/schemas/task.schema.ts
import { z } from "zod";
 
// === Base Schema ===
export const taskSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1, "Title is required").max(200),
  description: z.string().max(2000).optional(),
  status: z.enum(["todo", "in_progress", "done"]).default("todo"),
  priority: z.enum(["low", "medium", "high"]).default("medium"),
  dueDate: z.coerce.date().optional(),
  createdAt: z.date(),
  updatedAt: z.date(),
});
 
// === Request Schemas ===
export const createTaskSchema = z.object({
  body: z.object({
    title: z.string().min(1, "Title is required").max(200),
    description: z.string().max(2000).optional(),
    status: z.enum(["todo", "in_progress", "done"]).default("todo"),
    priority: z.enum(["low", "medium", "high"]).default("medium"),
    dueDate: z.coerce.date().optional(),
  }),
});
 
export const updateTaskSchema = z.object({
  body: z.object({
    title: z.string().min(1).max(200).optional(),
    description: z.string().max(2000).optional(),
    status: z.enum(["todo", "in_progress", "done"]).optional(),
    priority: z.enum(["low", "medium", "high"]).optional(),
    dueDate: z.coerce.date().optional(),
  }),
  params: z.object({
    id: z.string().uuid("Invalid task ID format"),
  }),
});
 
export const getTaskSchema = z.object({
  params: z.object({
    id: z.string().uuid("Invalid task ID format"),
  }),
});
 
export const listTasksSchema = z.object({
  query: z.object({
    status: z.enum(["todo", "in_progress", "done"]).optional(),
    priority: z.enum(["low", "medium", "high"]).optional(),
    page: z.coerce.number().int().positive().default(1),
    limit: z.coerce.number().int().positive().max(100).default(20),
    sortBy: z.enum(["createdAt", "dueDate", "priority"]).default("createdAt"),
    order: z.enum(["asc", "desc"]).default("desc"),
  }),
});
 
// === Inferred Types ===
export type Task = z.infer<typeof taskSchema>;
export type CreateTaskInput = z.infer<typeof createTaskSchema>["body"];
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>["body"];
export type ListTasksQuery = z.infer<typeof listTasksSchema>["query"];

The Key Insight

Notice how createTaskSchema wraps the body in z.object({ body: ... }). This matches Express's request structure (req.body, req.params, req.query), so our validation middleware can validate all three at once.

And the types are inferred from the schemas — no separate interface definitions to keep in sync.


4. Validation Middleware

A reusable middleware that validates any Zod schema against the request:

// src/middleware/validate.ts
import type { Request, Response, NextFunction } from "express";
import { AnyZodObject, ZodError } from "zod";
 
export function validate(schema: AnyZodObject) {
  return (req: Request, _res: Response, next: NextFunction) => {
    try {
      schema.parse({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        const details: Record<string, string[]> = {};
 
        for (const issue of error.issues) {
          const path = issue.path.join(".");
          if (!details[path]) {
            details[path] = [];
          }
          details[path].push(issue.message);
        }
 
        next({
          statusCode: 400,
          message: "Validation failed",
          code: "VALIDATION_ERROR",
          details,
        });
        return;
      }
      next(error);
    }
  };
}

Usage in routes:

// Later in routes — schema validates req.body, req.params, and req.query
router.post("/", validate(createTaskSchema), controller.create);
router.put("/:id", validate(updateTaskSchema), controller.update);
router.get("/", validate(listTasksSchema), controller.list);

If validation fails, the response looks like:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "body.title": ["Title is required"],
      "body.priority": ["Invalid enum value. Expected 'low' | 'medium' | 'high', received 'urgent'"]
    }
  }
}

5. Middleware Stack

Request ID

Every request gets a unique ID. Essential for debugging in production — correlate logs, trace errors back to specific requests.

// src/middleware/request-id.ts
import type { Request, Response, NextFunction } from "express";
import { v4 as uuidv4 } from "uuid";
 
export function requestId(req: Request, res: Response, next: NextFunction) {
  const id = (req.headers["x-request-id"] as string) ?? uuidv4();
  req.headers["x-request-id"] = id;
  res.setHeader("x-request-id", id);
  next();
}

Request Logger

Log every request with timing information:

// src/middleware/logger.ts
import type { Request, Response, NextFunction } from "express";
 
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const start = Date.now();
 
  res.on("finish", () => {
    const duration = Date.now() - start;
    const requestId = req.headers["x-request-id"] ?? "unknown";
 
    console.log(
      JSON.stringify({
        method: req.method,
        path: req.path,
        status: res.statusCode,
        duration: `${duration}ms`,
        requestId,
      }),
    );
  });
 
  next();
}

Global Error Handler

The safety net that catches everything:

// src/middleware/error-handler.ts
import type { Request, Response, NextFunction } from "express";
import { AppError } from "@/utils/errors.js";
 
interface ErrorResponse {
  error: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
    requestId?: string;
  };
}
 
export function errorHandler(
  err: Error & { statusCode?: number; code?: string; details?: Record<string, string[]> },
  req: Request,
  res: Response<ErrorResponse>,
  _next: NextFunction,
) {
  const statusCode = err.statusCode ?? 500;
  const code = err.code ?? "INTERNAL_ERROR";
  const message = statusCode === 500 ? "Internal server error" : err.message;
  const requestId = req.headers["x-request-id"] as string | undefined;
 
  // Log the full error for 500s
  if (statusCode === 500) {
    console.error("Unhandled error:", {
      message: err.message,
      stack: err.stack,
      requestId,
    });
  }
 
  res.status(statusCode).json({
    error: {
      code,
      message,
      details: err.details,
      requestId,
    },
  });
}

404 Handler

For routes that don't exist:

// src/middleware/not-found.ts
import type { Request, Response } from "express";
 
export function notFoundHandler(req: Request, res: Response) {
  res.status(404).json({
    error: {
      code: "NOT_FOUND",
      message: `Route ${req.method} ${req.path} not found`,
    },
  });
}

6. Repository Layer — Data Access

We'll use an in-memory store for now. The beauty of the repository pattern is that swapping to PostgreSQL + Prisma later requires changing only this file.

// src/repositories/task.repository.ts
import type { Task, CreateTaskInput, UpdateTaskInput, ListTasksQuery } from "@/schemas/task.schema.js";
import { v4 as uuidv4 } from "uuid";
 
// In-memory store (replace with database later)
const tasks: Map<string, Task> = new Map();
 
export class TaskRepository {
  async findMany(query: ListTasksQuery): Promise<{ tasks: Task[]; total: number }> {
    let results = Array.from(tasks.values());
 
    // Filter
    if (query.status) {
      results = results.filter((t) => t.status === query.status);
    }
    if (query.priority) {
      results = results.filter((t) => t.priority === query.priority);
    }
 
    // Sort
    results.sort((a, b) => {
      const field = query.sortBy;
      const aVal = a[field];
      const bVal = b[field];
 
      if (aVal === undefined || bVal === undefined) return 0;
      if (aVal < bVal) return query.order === "asc" ? -1 : 1;
      if (aVal > bVal) return query.order === "asc" ? 1 : -1;
      return 0;
    });
 
    const total = results.length;
 
    // Paginate
    const offset = (query.page - 1) * query.limit;
    results = results.slice(offset, offset + query.limit);
 
    return { tasks: results, total };
  }
 
  async findById(id: string): Promise<Task | undefined> {
    return tasks.get(id);
  }
 
  async create(data: CreateTaskInput): Promise<Task> {
    const now = new Date();
    const task: Task = {
      id: uuidv4(),
      ...data,
      status: data.status ?? "todo",
      priority: data.priority ?? "medium",
      createdAt: now,
      updatedAt: now,
    };
    tasks.set(task.id, task);
    return task;
  }
 
  async update(id: string, data: UpdateTaskInput): Promise<Task | undefined> {
    const existing = tasks.get(id);
    if (!existing) return undefined;
 
    const updated: Task = {
      ...existing,
      ...data,
      updatedAt: new Date(),
    };
    tasks.set(id, updated);
    return updated;
  }
 
  async delete(id: string): Promise<boolean> {
    return tasks.delete(id);
  }
 
  async existsByTitle(title: string, excludeId?: string): Promise<boolean> {
    for (const task of tasks.values()) {
      if (task.title === title && task.id !== excludeId) return true;
    }
    return false;
  }
}

7. Service Layer — Business Logic

The service layer handles business rules. It doesn't know about HTTP, Express, or request/response objects.

// src/services/task.service.ts
import { TaskRepository } from "@/repositories/task.repository.js";
import { NotFoundError, ConflictError } from "@/utils/errors.js";
import type { Task, CreateTaskInput, UpdateTaskInput, ListTasksQuery } from "@/schemas/task.schema.js";
 
export class TaskService {
  private repository = new TaskRepository();
 
  async list(query: ListTasksQuery): Promise<{ tasks: Task[]; total: number; page: number; limit: number }> {
    const { tasks, total } = await this.repository.findMany(query);
    return {
      tasks,
      total,
      page: query.page,
      limit: query.limit,
    };
  }
 
  async getById(id: string): Promise<Task> {
    const task = await this.repository.findById(id);
    if (!task) {
      throw new NotFoundError("Task", id);
    }
    return task;
  }
 
  async create(data: CreateTaskInput): Promise<Task> {
    // Business rule: no duplicate titles
    const exists = await this.repository.existsByTitle(data.title);
    if (exists) {
      throw new ConflictError(`Task with title '${data.title}' already exists`);
    }
    return this.repository.create(data);
  }
 
  async update(id: string, data: UpdateTaskInput): Promise<Task> {
    // Verify task exists
    await this.getById(id);
 
    // Business rule: check title uniqueness if title is being updated
    if (data.title) {
      const exists = await this.repository.existsByTitle(data.title, id);
      if (exists) {
        throw new ConflictError(`Task with title '${data.title}' already exists`);
      }
    }
 
    const updated = await this.repository.update(id, data);
    if (!updated) {
      throw new NotFoundError("Task", id);
    }
    return updated;
  }
 
  async delete(id: string): Promise<void> {
    // Verify task exists before deleting
    await this.getById(id);
    await this.repository.delete(id);
  }
}

Notice how the service:

  • Throws domain-specific errors (NotFoundError, ConflictError)
  • Enforces business rules (no duplicate titles)
  • Returns clean data — no HTTP status codes, no response objects

8. Controller Layer — HTTP Handling

Controllers translate between HTTP and business logic. They parse requests, call services, and format responses.

// src/controllers/task.controller.ts
import type { Request, Response, NextFunction } from "express";
import { TaskService } from "@/services/task.service.js";
import type { CreateTaskInput, UpdateTaskInput, ListTasksQuery } from "@/schemas/task.schema.js";
 
export class TaskController {
  private service = new TaskService();
 
  list = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const query = req.query as unknown as ListTasksQuery;
      const result = await this.service.list(query);
      res.json({
        data: result.tasks,
        pagination: {
          page: result.page,
          limit: result.limit,
          total: result.total,
          totalPages: Math.ceil(result.total / result.limit),
        },
      });
    } catch (error) {
      next(error);
    }
  };
 
  getById = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const task = await this.service.getById(req.params.id!);
      res.json({ data: task });
    } catch (error) {
      next(error);
    }
  };
 
  create = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const input = req.body as CreateTaskInput;
      const task = await this.service.create(input);
      res.status(201).json({ data: task });
    } catch (error) {
      next(error);
    }
  };
 
  update = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const input = req.body as UpdateTaskInput;
      const task = await this.service.update(req.params.id!, input);
      res.json({ data: task });
    } catch (error) {
      next(error);
    }
  };
 
  delete = async (req: Request, res: Response, next: NextFunction) => {
    try {
      await this.service.delete(req.params.id!);
      res.status(204).send();
    } catch (error) {
      next(error);
    }
  };
}

Pattern: Arrow Functions for Methods

Notice list = async (req, res, next) => { ... } instead of async list(req, res, next) { ... }.

Arrow functions preserve this binding. Without them, passing controller.list to Express as a callback loses the this context:

// BUG: `this` is undefined inside the handler
router.get("/", controller.list); // controller.list is a regular method
 
// WORKS: Arrow function captures `this` from the class
router.get("/", controller.list); // controller.list is an arrow function property

9. Routes

Wire everything together with Express Router:

// src/routes/task.routes.ts
import { Router } from "express";
import { TaskController } from "@/controllers/task.controller.js";
import { validate } from "@/middleware/validate.js";
import {
  createTaskSchema,
  updateTaskSchema,
  getTaskSchema,
  listTasksSchema,
} from "@/schemas/task.schema.js";
 
const router = Router();
const controller = new TaskController();
 
router.get("/", validate(listTasksSchema), controller.list);
router.get("/:id", validate(getTaskSchema), controller.getById);
router.post("/", validate(createTaskSchema), controller.create);
router.put("/:id", validate(updateTaskSchema), controller.update);
router.delete("/:id", validate(getTaskSchema), controller.delete);
 
export default router;

Route Aggregator with API Versioning

// src/routes/index.ts
import { Router } from "express";
import taskRoutes from "@/routes/task.routes.js";
 
const router = Router();
 
// API v1
router.use("/tasks", taskRoutes);
 
// Health check (unversioned)
router.get("/health", (_req, res) => {
  res.json({
    status: "ok",
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
  });
});
 
export default router;

API Versioning Strategy

Mount the route aggregator under a versioned prefix:

// In app.ts (shown in full below)
app.use("/api/v1", routes);

This gives you endpoints like:

  • GET /api/v1/tasks
  • POST /api/v1/tasks
  • GET /api/v1/health

When v2 is needed, create new routes and mount alongside v1:

app.use("/api/v1", routesV1);
app.use("/api/v2", routesV2); // New version, old still works

10. App Setup

Bring it all together:

// src/app.ts
import express from "express";
import routes from "@/routes/index.js";
import { requestId } from "@/middleware/request-id.js";
import { requestLogger } from "@/middleware/logger.js";
import { errorHandler } from "@/middleware/error-handler.js";
import { notFoundHandler } from "@/middleware/not-found.js";
 
const app = express();
 
// === Core Middleware ===
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true }));
 
// === Custom Middleware ===
app.use(requestId);
app.use(requestLogger);
 
// === Routes ===
app.use("/api/v1", routes);
 
// === Error Handling (must be last) ===
app.use(notFoundHandler);
app.use(errorHandler);
 
export default app;
// src/index.ts
import app from "@/app.js";
import { env } from "@/config/env.js";
 
app.listen(env.PORT, env.HOST, () => {
  console.log(`Server running at http://${env.HOST}:${env.PORT}`);
  console.log(`Environment: ${env.NODE_ENV}`);
  console.log(`API docs: http://${env.HOST}:${env.PORT}/api/v1/health`);
});

Middleware Order Matters

The order middleware is registered is the order it executes:

  1. Body parsing first — downstream middleware needs req.body
  2. Request ID early — all middleware and logs can reference it
  3. Logger before routes — captures timing for the full request
  4. Routes — your business logic
  5. 404 handler after routes — catches unmatched paths
  6. Error handler last — catches thrown errors from anywhere above

11. Testing the API

Start the server:

npm run dev

Create a Task

curl -X POST http://localhost:3000/api/v1/tasks \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Learn TypeScript",
    "description": "Complete the TypeScript backend series",
    "priority": "high",
    "dueDate": "2026-03-15"
  }'

Response:

{
  "data": {
    "id": "a1b2c3d4-...",
    "title": "Learn TypeScript",
    "description": "Complete the TypeScript backend series",
    "status": "todo",
    "priority": "high",
    "dueDate": "2026-03-15T00:00:00.000Z",
    "createdAt": "2026-03-01T10:00:00.000Z",
    "updatedAt": "2026-03-01T10:00:00.000Z"
  }
}

List Tasks with Filters

# All tasks, page 1
curl http://localhost:3000/api/v1/tasks
 
# Filter by status
curl "http://localhost:3000/api/v1/tasks?status=todo&priority=high"
 
# Paginated
curl "http://localhost:3000/api/v1/tasks?page=1&limit=10&sortBy=dueDate&order=asc"

Update a Task

curl -X PUT http://localhost:3000/api/v1/tasks/a1b2c3d4-... \
  -H "Content-Type: application/json" \
  -d '{"status": "in_progress"}'

Delete a Task

curl -X DELETE http://localhost:3000/api/v1/tasks/a1b2c3d4-...
# 204 No Content

Validation Error

curl -X POST http://localhost:3000/api/v1/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "", "priority": "urgent"}'
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "body.title": ["Title is required"],
      "body.priority": ["Invalid enum value. Expected 'low' | 'medium' | 'high', received 'urgent'"]
    }
  }
}

404 Error

curl http://localhost:3000/api/v1/tasks/non-existent-uuid
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Task with id 'non-existent-uuid' not found",
    "requestId": "f47ac10b-..."
  }
}

12. OpenAPI/Swagger Documentation

Auto-generate API docs so consumers don't have to guess your endpoints.

npm install swagger-ui-express swagger-jsdoc
npm install -D @types/swagger-ui-express @types/swagger-jsdoc

Create the OpenAPI spec:

// src/config/swagger.ts
import swaggerJsdoc from "swagger-jsdoc";
 
const options: swaggerJsdoc.Options = {
  definition: {
    openapi: "3.0.0",
    info: {
      title: "Task Management API",
      version: "1.0.0",
      description: "A REST API for managing tasks, built with Express.js and TypeScript",
    },
    servers: [
      {
        url: "/api/v1",
        description: "API v1",
      },
    ],
    components: {
      schemas: {
        Task: {
          type: "object",
          properties: {
            id: { type: "string", format: "uuid" },
            title: { type: "string", maxLength: 200 },
            description: { type: "string", maxLength: 2000 },
            status: { type: "string", enum: ["todo", "in_progress", "done"] },
            priority: { type: "string", enum: ["low", "medium", "high"] },
            dueDate: { type: "string", format: "date-time" },
            createdAt: { type: "string", format: "date-time" },
            updatedAt: { type: "string", format: "date-time" },
          },
        },
        CreateTask: {
          type: "object",
          required: ["title"],
          properties: {
            title: { type: "string", minLength: 1, maxLength: 200 },
            description: { type: "string", maxLength: 2000 },
            status: { type: "string", enum: ["todo", "in_progress", "done"], default: "todo" },
            priority: { type: "string", enum: ["low", "medium", "high"], default: "medium" },
            dueDate: { type: "string", format: "date-time" },
          },
        },
        Error: {
          type: "object",
          properties: {
            error: {
              type: "object",
              properties: {
                code: { type: "string" },
                message: { type: "string" },
                details: { type: "object" },
                requestId: { type: "string" },
              },
            },
          },
        },
      },
    },
  },
  apis: ["./src/routes/*.ts"],
};
 
export const swaggerSpec = swaggerJsdoc(options);

Add JSDoc annotations to routes:

// src/routes/task.routes.ts
import { Router } from "express";
import { TaskController } from "@/controllers/task.controller.js";
import { validate } from "@/middleware/validate.js";
import {
  createTaskSchema,
  updateTaskSchema,
  getTaskSchema,
  listTasksSchema,
} from "@/schemas/task.schema.js";
 
const router = Router();
const controller = new TaskController();
 
/**
 * @openapi
 * /tasks:
 *   get:
 *     summary: List all tasks
 *     parameters:
 *       - in: query
 *         name: status
 *         schema:
 *           type: string
 *           enum: [todo, in_progress, done]
 *       - in: query
 *         name: priority
 *         schema:
 *           type: string
 *           enum: [low, medium, high]
 *       - in: query
 *         name: page
 *         schema:
 *           type: integer
 *           default: 1
 *       - in: query
 *         name: limit
 *         schema:
 *           type: integer
 *           default: 20
 *     responses:
 *       200:
 *         description: List of tasks with pagination
 */
router.get("/", validate(listTasksSchema), controller.list);
 
/**
 * @openapi
 * /tasks/{id}:
 *   get:
 *     summary: Get a task by ID
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: string
 *           format: uuid
 *     responses:
 *       200:
 *         description: Task details
 *       404:
 *         description: Task not found
 */
router.get("/:id", validate(getTaskSchema), controller.getById);
 
/**
 * @openapi
 * /tasks:
 *   post:
 *     summary: Create a new task
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/CreateTask'
 *     responses:
 *       201:
 *         description: Task created
 *       400:
 *         description: Validation error
 *       409:
 *         description: Task with same title exists
 */
router.post("/", validate(createTaskSchema), controller.create);
 
/**
 * @openapi
 * /tasks/{id}:
 *   put:
 *     summary: Update a task
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: string
 *           format: uuid
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/CreateTask'
 *     responses:
 *       200:
 *         description: Task updated
 *       404:
 *         description: Task not found
 */
router.put("/:id", validate(updateTaskSchema), controller.update);
 
/**
 * @openapi
 * /tasks/{id}:
 *   delete:
 *     summary: Delete a task
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: string
 *           format: uuid
 *     responses:
 *       204:
 *         description: Task deleted
 *       404:
 *         description: Task not found
 */
router.delete("/:id", validate(getTaskSchema), controller.delete);
 
export default router;

Mount Swagger UI in your app:

// Add to src/app.ts
import swaggerUi from "swagger-ui-express";
import { swaggerSpec } from "@/config/swagger.js";
 
// After other middleware, before routes
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));

Now visit http://localhost:3000/api/docs for interactive API documentation.


13. Architecture Overview

Here's how all the layers fit together:

Data flows down: Request → Middleware → Route → Controller → Service → Repository → Database

Errors flow up: Any layer can throw → Error Handler catches → Consistent JSON response

Each layer has one job:

  • Middleware: Cross-cutting concerns (auth, logging, validation)
  • Controller: HTTP in/out
  • Service: Business rules
  • Repository: Data access

Summary

Here's what we built and the patterns we used:

LayerResponsibilityFiles
SchemasValidation + Typesschemas/task.schema.ts
MiddlewareCross-cutting concernsmiddleware/*.ts
RoutesEndpoint definitionsroutes/task.routes.ts
ControllersHTTP handlingcontrollers/task.controller.ts
ServicesBusiness logicservices/task.service.ts
RepositoriesData accessrepositories/task.repository.ts
ErrorsConsistent error typesutils/errors.ts

Key takeaways:

  • Zod gives you validation and types from a single source of truth
  • Layered architecture makes each piece testable and replaceable
  • Custom error classes enable consistent, predictable API responses
  • Middleware order matters — parse body before logging, handle errors last
  • API versioning with URL prefixes (/api/v1/) is simple and effective

What's Next

In the next post, we'll connect this API to a real database:

  • Prisma ORM setup and schema design
  • Migrations workflow
  • CRUD with full type safety
  • Transactions and connection pooling
  • Testing database code

Series: TypeScript Full-Stack Development
Previous: TypeScript: Setup Project for Node.js Backend
Next: TypeScript: Express.js Database Integration (coming soon)

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.