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/expressAlso install utilities we'll use throughout:
npm install uuid
npm install -D @types/uuidYour 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 classes2. 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 property9. 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/tasksPOST /api/v1/tasksGET /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 works10. 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:
- Body parsing first — downstream middleware needs
req.body - Request ID early — all middleware and logs can reference it
- Logger before routes — captures timing for the full request
- Routes — your business logic
- 404 handler after routes — catches unmatched paths
- Error handler last — catches thrown errors from anywhere above
11. Testing the API
Start the server:
npm run devCreate 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 ContentValidation 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-jsdocCreate 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:
| Layer | Responsibility | Files |
|---|---|---|
| Schemas | Validation + Types | schemas/task.schema.ts |
| Middleware | Cross-cutting concerns | middleware/*.ts |
| Routes | Endpoint definitions | routes/task.routes.ts |
| Controllers | HTTP handling | controllers/task.controller.ts |
| Services | Business logic | services/task.service.ts |
| Repositories | Data access | repositories/task.repository.ts |
| Errors | Consistent error types | utils/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.