TypeScript REST API Project | Express & Layered Architecture

Key takeaways

TypeScript REST API tutorial: Express, tsconfig, DTOs (Omit/Partial), controller/service/database layers, ApiResponse JSON, and curl examples for user CRUD.

Introduction

Let’s build a type-safe REST API with TypeScript and Express. Rather than throwing every route handler into a single index.ts file, this project applies a layered architecture: requests flow through a router, a controller, a service, and a data-access layer, each with one clearly bounded responsibility. That separation is the real subject of this tutorial — the CRUD logic itself is deliberately simple so the architecture stays easy to follow.

Why bother with layers for a small API?

A single-file Express app is fine for a weekend script, but it breaks down quickly once real requirements show up: validation rules that need to be reused across create and update, business logic that has to run the same way whether it’s triggered by an HTTP request or a background job, or a database that needs to be swapped from an in-memory Map to PostgreSQL without touching the HTTP layer. Mixing all of that into one file means every change risks breaking something unrelated, and every unit test has to spin up a full HTTP server just to check a validation rule.

Splitting the code into router → controller → service → repository (database) layers fixes this by giving each piece a single reason to change:

  • The router only maps an HTTP verb and path to a handler function. It knows nothing about validation, business rules, or storage.
  • The controller translates between HTTP concerns (req, res, status codes, JSON shape) and the application’s internal types. It has no business logic of its own.
  • The service holds the actual business rules — validation, orchestration, side effects — and is completely unaware of Express. It could be called from a CLI script or a cron job just as easily as from userController.
  • The repository/database layer is the only place that knows how data is actually stored. Everything above it talks to plain TypeScript objects, not SQL rows or Map internals.

This diagram shows how a single “create user” request moves through those layers:

sequenceDiagram
    participant Client
    participant Router
    participant Controller
    participant Service
    participant Database

    Client->>Router: POST /api/users
    Router->>Controller: createUser(req, res)
    Controller->>Service: createUser(dto)
    Service->>Service: validateEmail / validateAge
    Service->>Database: create(dto)
    Database-->>Service: User
    Service-->>Controller: User
    Controller-->>Client: 201 ApiResponse<UserResponse>

Notice that errors thrown by the service (an invalid email, for example) bubble straight up to the controller’s catch block — the service never touches res directly. That inversion is what makes the service layer reusable outside of HTTP, and it’s the single most important habit to take away from this project.


Project setup

Initialize

mkdir typescript-api
cd typescript-api
npm init -y
npm install express
npm install --save-dev typescript @types/node @types/express ts-node nodemon

express itself ships without type definitions, which is why @types/express and @types/node are separate dev dependencies — they describe the shape of Request, Response, and Node’s built-in modules so the compiler can check your handler signatures. ts-node lets nodemon execute TypeScript files directly during development instead of requiring a manual tsc build on every save; in production you compile once with tsc and run the plain JavaScript output, which is faster to start and doesn’t need ts-node installed on the server at all.

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

A few of these options matter more than they look. strict: true turns on the full family of strict checks (strictNullChecks, noImplicitAny, and friends) in one flag — without it, a typo like user.emial on an optional field would silently produce undefined at runtime instead of failing at compile time. esModuleInterop makes import express from "express" work correctly for CommonJS packages that don’t use ES module export default natively; omitting it is one of the most common sources of confusing “module has no default export” errors when mixing TypeScript with older npm packages. skipLibCheck skips type-checking inside .d.ts files from node_modules, which meaningfully speeds up compilation on a project with many dependencies and avoids build failures caused by type errors in third-party packages you don’t control. Finally, rootDir/outDir keep the compiled output in dist/ cleanly separated from source, which matters once you start deploying only the compiled JavaScript.

package.json scripts

{
  "scripts": {
    "dev": "nodemon --exec ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

The three scripts map to the three stages of the project’s lifecycle: dev for local iteration with automatic restarts, build to type-check and emit JavaScript, and start to run that emitted output the way a process manager (PM2, systemd, a Docker CMD) would in production. Keeping start free of ts-node means a production container doesn’t need TypeScript or dev dependencies installed at all — npm ci --omit=dev after npm run build is enough, which shrinks the image and removes an entire class of “works on my machine” issues caused by dev-only tooling leaking into prod.


Type definitions

src/types/user.ts

export interface User {
    id: string;
    name: string;
    email: string;
    age: number;
    createdAt: Date;
}
export type CreateUserDto = Omit<User, "id" | "createdAt">;
export type UpdateUserDto = Partial<CreateUserDto>;
export type UserResponse = Omit<User, "createdAt"> & {
    createdAt: string;
};

This is the part of the project that most benefits from TypeScript specifically: three related-but-distinct shapes derived from a single source of truth (User) using utility types instead of three hand-written, easily-out-of-sync interfaces. CreateUserDto removes id and createdAt because the server assigns those — a client should never be able to specify its own primary key or timestamp. UpdateUserDto wraps that in Partial<...> because a PUT/PATCH request typically changes only a subset of fields; without Partial, TypeScript would require every field on every update call. UserResponse exists because Date objects don’t survive JSON.stringify as Date instances — they serialize to ISO strings — so the type that actually goes out over the wire needs createdAt: string, not createdAt: Date. Modeling that explicitly (instead of just casting to any at the response boundary) means a mismatch between what you serialize and what you claim to return shows up as a compile error, not a runtime surprise a client discovers later.

The general pattern worth internalizing here is: never reuse your internal domain model as your API’s public contract. A domain object often carries fields you don’t want to expose (password hashes, internal flags, soft-delete markers), and it can change shape for internal reasons that have nothing to do with the API’s public promises. DTOs decouple those two concerns, and Omit/Partial/Pick let you derive them without duplicating field lists that will inevitably drift.

src/types/api.ts

export interface ApiResponse<T> {
    success: boolean;
    data?: T;
    error?: string;
}
export interface PaginatedResponse<T> {
    items: T[];
    total: number;
    page: number;
    pageSize: number;
}

ApiResponse<T> gives every endpoint in the API the same envelope shape — clients can always check success first and then safely narrow to either data or error, instead of guessing the response shape per endpoint or inferring success from the HTTP status code alone (which is fragile once proxies, gateways, or retries are involved). PaginatedResponse<T> isn’t wired up in this tutorial’s getUsers endpoint yet — it’s included because a findAll method that returns every row unconditionally is one of the most common scaling mistakes in a first REST API. The moment the user table has more than a few hundred rows, an unpaginated list endpoint turns into an unbounded payload and an easy way to degrade the server under load; reaching for PaginatedResponse (with page/pageSize query parameters wired into the service layer) before that happens is much cheaper than retrofitting pagination onto an API that clients already depend on.


In-memory database

src/database/users.ts

import { User } from "../types/user";
class UserDatabase {
    private users: Map<string, User> = new Map();
    private currentId = 1;
    
    create(data: Omit<User, "id" | "createdAt">): User {
        const user: User = {
            id: `U${String(this.currentId++).padStart(3, "0")}`,
            ...data,
            createdAt: new Date()
        };
        this.users.set(user.id, user);
        return user;
    }
    
    findAll(): User[] {
        return Array.from(this.users.values());
    }
    
    findById(id: string): User | undefined {
        return this.users.get(id);
    }
    
    update(id: string, data: Partial<User>): User | undefined {
        const user = this.users.get(id);
        if (!user) return undefined;
        
        const updated = { ...user, ...data };
        this.users.set(id, updated);
        return updated;
    }
    
    delete(id: string): boolean {
        return this.users.delete(id);
    }
}
export const userDb = new UserDatabase();

This class is standing in for what would normally be a repository backed by a real database — think of it as a mock implementation of the same interface a Prisma- or TypeORM-backed repository would expose. Using a Map instead of an array keeps findById/update/delete at O(1) instead of O(n), which matters less for a tutorial but is the same reasoning you’d apply to an indexed column in a real database. The important design decision here isn’t the storage mechanism — it’s that create, findAll, findById, update, and delete form a small, stable interface that the service layer depends on. Because the service only calls these five methods and never touches this.users directly, swapping this class for one backed by Prisma or a raw SQL client later means changing exactly one file; nothing in userService.ts or above needs to know storage changed.

Two limitations are worth calling out explicitly so they don’t get carried into a real project by accident. First, Map state lives in process memory: it resets on every restart and isn’t shared across multiple server instances, which makes this implementation unsuitable for anything beyond local development and testing. Second, none of these methods are actually asynchronous — they return plain values, not Promises — which works here only because the service layer already wraps every call in an async method and awaits it. That’s intentional: it means the service layer’s calling convention doesn’t need to change at all when userDb is later replaced with something that performs real (asynchronous) I/O.


Service layer

src/services/userService.ts

import { userDb } from "../database/users";
import { CreateUserDto, UpdateUserDto, User } from "../types/user";
export class UserService {
    async createUser(data: CreateUserDto): Promise<User> {
        this.validateEmail(data.email);
        this.validateAge(data.age);
        
        return userDb.create(data);
    }
    
    async getUsers(): Promise<User[]> {
        return userDb.findAll();
    }
    
    async getUserById(id: string): Promise<User | undefined> {
        return userDb.findById(id);
    }
    
    async updateUser(id: string, data: UpdateUserDto): Promise<User | undefined> {
        if (data.email) {
            this.validateEmail(data.email);
        }
        if (data.age !== undefined) {
            this.validateAge(data.age);
        }
        
        return userDb.update(id, data);
    }
    
    async deleteUser(id: string): Promise<boolean> {
        return userDb.delete(id);
    }
    
    private validateEmail(email: string): void {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailRegex.test(email)) {
            throw new Error("Invalid email format");
        }
    }
    
    private validateAge(age: number): void {
        if (age < 0 || age > 150) {
            throw new Error("Age must be between 0 and 150");
        }
    }
}
export const userService = new UserService();

This is where the actual business rules live, and it’s deliberately the only layer that knows what “valid” means for a user. Notice that validateEmail/validateAge throw plain Error instances rather than building an HTTP response — the service has no concept of status codes or JSON envelopes, because it shouldn’t. That separation is what lets you call userService.createUser(...) from a seed script, a message-queue consumer, or a test file without ever touching Express. If validation logic were embedded in the controller instead (a common shortcut), none of those other call sites could reuse it without either duplicating the checks or routing everything through an HTTP request to itself.

The updateUser method is worth a closer look because it illustrates a subtle but important difference between create and update validation: on create, every field is guaranteed present (the CreateUserDto type enforces that), so validateEmail/validateAge always run. On update, fields are optional (UpdateUserDto is Partial<...>), so the checks are guarded by if (data.email) / if (data.age !== undefined) — an update that doesn’t touch age shouldn’t be rejected just because age isn’t present. It’s easy to get this backwards and either validate fields that weren’t submitted (rejecting valid partial updates) or skip validation on fields that were submitted (letting bad data through). Writing the guard conditions to exactly match the DTO’s optionality is what keeps the two in sync.

In a production system you’d typically replace the hand-rolled regex and range check with a schema-validation library like Zod or class-validator, both of which can validate an entire DTO in one call, produce structured error messages per field, and — in Zod’s case — infer the TypeScript type directly from the schema so the validation rules and the type definition can never drift apart. The manual approach here is kept intentionally simple so the layering stays the focus, but treat validateEmail/validateAge as a placeholder you’d outgrow quickly.


Controllers

src/controllers/userController.ts

import { Request, Response } from "express";
import { userService } from "../services/userService";
import { ApiResponse } from "../types/api";
import { UserResponse } from "../types/user";
export class UserController {
    async createUser(req: Request, res: Response): Promise<void> {
        try {
            const user = await userService.createUser(req.body);
            
            const response: ApiResponse<UserResponse> = {
                success: true,
                data: {
                    ...user,
                    createdAt: user.createdAt.toISOString()
                }
            };
            
            res.status(201).json(response);
        } catch (error) {
            const response: ApiResponse<never> = {
                success: false,
                error: error instanceof Error ? error.message : "Unknown error"
            };
            res.status(400).json(response);
        }
    }
    
    async getUsers(req: Request, res: Response): Promise<void> {
        try {
            const users = await userService.getUsers();
            
            const response: ApiResponse<UserResponse[]> = {
                success: true,
                data: users.map(user => ({
                    ...user,
                    createdAt: user.createdAt.toISOString()
                }))
            };
            
            res.json(response);
        } catch (error) {
            const response: ApiResponse<never> = {
                success: false,
                error: error instanceof Error ? error.message : "Unknown error"
            };
            res.status(500).json(response);
        }
    }
    
    async getUserById(req: Request, res: Response): Promise<void> {
        try {
            const user = await userService.getUserById(req.params.id);
            
            if (!user) {
                const response: ApiResponse<never> = {
                    success: false,
                    error: "User not found"
                };
                res.status(404).json(response);
                return;
            }
            
            const response: ApiResponse<UserResponse> = {
                success: true,
                data: {
                    ...user,
                    createdAt: user.createdAt.toISOString()
                }
            };
            
            res.json(response);
        } catch (error) {
            const response: ApiResponse<never> = {
                success: false,
                error: error instanceof Error ? error.message : "Unknown error"
            };
            res.status(500).json(response);
        }
    }
    
    async updateUser(req: Request, res: Response): Promise<void> {
        try {
            const user = await userService.updateUser(req.params.id, req.body);
            
            if (!user) {
                const response: ApiResponse<never> = {
                    success: false,
                    error: "User not found"
                };
                res.status(404).json(response);
                return;
            }
            
            const response: ApiResponse<UserResponse> = {
                success: true,
                data: {
                    ...user,
                    createdAt: user.createdAt.toISOString()
                }
            };
            
            res.json(response);
        } catch (error) {
            const response: ApiResponse<never> = {
                success: false,
                error: error instanceof Error ? error.message : "Unknown error"
            };
            res.status(400).json(response);
        }
    }
    
    async deleteUser(req: Request, res: Response): Promise<void> {
        try {
            const deleted = await userService.deleteUser(req.params.id);
            
            if (!deleted) {
                const response: ApiResponse<never> = {
                    success: false,
                    error: "User not found"
                };
                res.status(404).json(response);
                return;
            }
            
            const response: ApiResponse<{ message: string }> = {
                success: true,
                data: { message: "User deleted" }
            };
            
            res.json(response);
        } catch (error) {
            const response: ApiResponse<never> = {
                success: false,
                error: error instanceof Error ? error.message : "Unknown error"
            };
            res.status(500).json(response);
        }
    }
}
export const userController = new UserController();

The controller’s job is narrow on purpose: pull data out of req, hand it to the service, and translate whatever comes back into an HTTP status code and JSON body. It never contains an if (age < 0) check or a database call — if you find business logic creeping into a controller method, that’s usually a sign it belongs in the service instead. Keeping controllers this thin also makes them easy to reason about even though there are five of them repeating a similar try/catch shape: each method answers exactly one question — “how does this particular outcome map to an HTTP response?”

A couple of details are easy to get wrong when writing this kind of controller. Notice that createUser and updateUser respond with 400 Bad Request when the service throws (a validation failure is the caller’s fault), while getUsers and getUserById respond with 500 Internal Server Error in their catch block (an unexpected failure reading data is the server’s fault) — mixing these up is a common mistake that makes API errors harder for clients to handle programmatically, since a 400 tells a client “fix your request” and a 500 tells it “retry later, this isn’t your fault.” Also note the early return after res.status(404).json(response) in getUserById, updateUser, and deleteUser — without it, execution would fall through to the success-response code below and Express would throw Cannot set headers after they are sent to the client, because you’d be calling res.json() twice for the same request. This is one of the most common runtime errors reported by developers new to Express and TypeScript together, precisely because TypeScript’s type checker doesn’t catch it — a missing early return is a control-flow bug, not a type error.

At production scale, five copies of nearly identical try/catch/error-shaping logic is also a maintenance smell worth fixing before it spreads to a tenth or twentieth controller method. The standard fix is an Express error-handling middleware: controller methods call next(error) (or simply don’t catch at all, if you’re on Express 5 or wrap routes in a small asyncHandler helper on Express 4) instead of building the error response inline, and one centralized middleware function turns any thrown error into a consistent ApiResponse shape, decides the status code based on the error’s type, and can add centralized logging in one place instead of five.


Router

src/routes/userRoutes.ts

import { Router } from "express";
import { userController } from "../controllers/userController";
const router = Router();
router.post("/", (req, res) => userController.createUser(req, res));
router.get("/", (req, res) => userController.getUsers(req, res));
router.get("/:id", (req, res) => userController.getUserById(req, res));
router.put("/:id", (req, res) => userController.updateUser(req, res));
router.delete("/:id", (req, res) => userController.deleteUser(req, res));
export default router;

The router is intentionally the thinnest layer in the whole stack — its only job is mapping an HTTP method and path onto a controller method, which is why each line here reads almost like documentation of the API’s surface. Following REST conventions (POST / to create, GET / to list, GET /:id to read one, PUT /:id to update, DELETE /:id to remove) means a developer unfamiliar with this codebase can predict most of the API just from reading this one file, without opening a single controller. Keep this pattern in mind as the API grows: once you have more than one resource, each gets its own Router instance mounted at its own path prefix (as shown in the next section), rather than one router file accumulating routes for users, orders, and products all at once.

One thing worth double-checking here is route ordering. Express matches routes top-to-bottom, so a more specific literal path (GET /me, say, for “the current user”) must be declared before a parameterized path like GET /:id — otherwise Express matches /:id first and treats "me" as an id value. This file doesn’t hit that trap yet because there’s only one dynamic segment, but it’s one of the most common routing bugs once an API grows past basic CRUD.


Entry point

src/index.ts

import express from "express";
import userRoutes from "./routes/userRoutes";
const app = express();
const PORT = 3000;
app.use(express.json());
app.use("/api/users", userRoutes);
app.get("/", (req, res) => {
    res.json({ message: "TypeScript API server" });
});
app.listen(PORT, () => {
    console.log(`Server running: http://localhost:${PORT}`);
});

This file wires everything together, and the order of the two app.use() calls matters more than it looks. express.json() is middleware that parses an incoming request’s JSON body into req.body before any route handler runs — without registering it first, req.body would be undefined in every controller, and userService.createUser(req.body) would fail immediately. Mounting userRoutes at the /api/users prefix is what turns the relative paths inside userRoutes.ts ("/", "/:id") into the full paths clients actually call (/api/users, /api/users/:id) — the router file itself never needs to know its own mount point, which is exactly what makes it reusable if you later decide to serve the same routes under /v1/users as well.

In a real deployment, this file typically grows a few more concerns before it’s production-ready: a helmet() call for basic security headers, a CORS middleware if the API is called from a browser on a different origin, request logging (morgan or a structured logger), and — critically — the centralized error-handling middleware mentioned above, registered last with app.use((err, req, res, next) => { ... }). None of that changes the shape of this tutorial’s architecture; it all slots in around the router/controller/service/database layers already in place.


Testing

Run the server

npm run dev

Try the API

# Create user
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Jane Doe","email":"[email protected]","age":25}'
# List users
curl http://localhost:3000/api/users
# Get one user
curl http://localhost:3000/api/users/U001
# Update user
curl -X PUT http://localhost:3000/api/users/U001 \
  -H "Content-Type: application/json" \
  -d '{"name":"John Smith"}'
# Delete user
curl -X DELETE http://localhost:3000/api/users/U001

Running through these five curl calls in order exercises every branch built above: a successful create returns 201 with the new user’s generated id; listing confirms the record persisted in the Map; fetching by id exercises the 404 path if you typo the id; updating with only name confirms UpdateUserDto’s partial validation guard works correctly; and deleting followed by a repeat GET on the same id confirms the “not found” response is returned on the second attempt. It’s worth deliberately trying a couple of failure cases too — an invalid email ("not-an-email") or an out-of-range age (200) — to confirm the service layer’s validation actually produces a 400 with a useful error message, rather than crashing the process or returning a 500.

Manual curl testing is a good sanity check while building, but it doesn’t scale as a permanent testing strategy — nobody re-runs five curl commands by hand before every deploy. Once the architecture above is in place, it becomes straightforward to add Jest or Vitest tests that call userService methods directly (no HTTP server needed, since the service has no Express dependency) for fast unit tests of the validation rules, plus a smaller set of integration tests using supertest against the Express app to confirm the controller and router wiring behaves as expected end-to-end. That split — many fast unit tests on the service layer, a few slower integration tests through HTTP — is a direct payoff of keeping the layers decoupled in the first place.


Mistakes to avoid as this project grows

  • Business logic in controllers. If a controller method has an if statement that isn’t about HTTP status codes, it’s usually service logic that leaked upward.
  • Reusing the domain model as the API response. Returning User directly (instead of UserResponse) risks leaking internal fields and breaks the moment a field’s on-the-wire representation differs from its in-memory type, as with Date vs. ISO string here.
  • Missing early return after an error response. Sending a response and then falling through to send another one is a frequent source of the “headers already sent” crash.
  • Unbounded list endpoints. findAll() without pagination works fine with ten rows in memory and becomes a real problem with ten thousand rows in a database.
  • Treating this in-memory store as production-ready. It has no persistence across restarts, no concurrency safety, and no support for running more than one server instance — swap it out before shipping anything real.