TypeScript + Node.js: Build a Type-Safe REST API from Scratch

Learn to build a robust, type-safe REST API using Node.js and TypeScript. This deep-dive covers setup, architecture, type definitions, controllers, services, and error handling for production-ready applications.

T

TechScale

October 8, 2026 14 min read
Share:
TypeScript + Node.js: Build a Type-Safe REST API from Scratch

The Peril of Untyped APIs: Why Type-Safety Matters

Building robust backend services often involves navigating a landscape of incoming requests and outgoing responses. In the JavaScript ecosystem, the dynamic nature of the language can quickly become a double-edged sword: flexibility at the cost of predictability. While Node.js provides an incredibly powerful runtime for server-side applications, the lack of static types in vanilla JavaScript can lead to common pitfalls:

  • Runtime Errors: Bugs discovered only when the code executes, often in production.
  • Poor Maintainability: Codebases become harder to understand and refactor as they grow.
  • API Inconsistency: It's easy for API endpoints to diverge from expected input/output schemas without strict enforcement.
  • Increased Development Time: Developers spend more time debugging type-related issues instead of building features.

This is where TypeScript shines. By introducing a static type system to JavaScript, TypeScript empowers developers to catch errors early, improve code quality, and enhance developer experience. In this comprehensive guide, we'll walk through building a complete, type-safe REST API from scratch using Node.js and TypeScript, ensuring your backend is as robust and predictable as it is powerful.

High-Level System Architecture: A Type-Safe Flow

Our type-safe REST API will adhere to a clean, layered architectural pattern, promoting separation of concerns and testability. The core flow of a request through our system will be:

HTTP Request

↓

Controller: The entry point, responsible for parsing the incoming HTTP Request (headers, body, query parameters) and validating its structure. It delegates business logic to the Service layer and formats the HTTP Response.

↓

Service: Contains the core business logic. It orchestrates operations, interacts with data sources (e.g., databases, external APIs), and enforces business rules. Services operate on well-defined types, ensuring type-safety throughout the data flow.

↓

Database / External API: The data persistence or external integration layer. The service interacts with these resources to fetch or store data.

↓

Typed Response: The final data returned from the service, strictly typed, which the controller then uses to construct the HTTP Response.

This layered approach, combined with TypeScript's static typing, will provide a solid foundation for a scalable and maintainable API. We will also define clear project structure to keep things organized.

Setting Up Your Type-Safe Node.js Project

First, let's initialize a new Node.js project and install the necessary dependencies. We'll use Express.js as our web framework, along with TypeScript and its types for Node.js and Express.

bash
bash
mkdir type-safe-api
cd type-safe-api
npm init -y
npm install express dotenv
npm install -D typescript @types/node @types/express ts-node-dev

Here's a breakdown of the packages:

  • express: Fast, unopinionated, minimalist web framework for Node.js.
  • dotenv: Loads environment variables from a .env file.
  • typescript: The TypeScript compiler itself.
  • @types/node: Type definitions for Node.js.
  • @types/express: Type definitions for Express.js.
  • ts-node-dev: A tool that restarts your Node.js application when changes are detected, compiling TypeScript on the fly (great for development).

Next, let's configure our package.json with convenient scripts and set up our TypeScript configuration file, tsconfig.json.

package.json
json
{
  "name": "type-safe-api",
  "version": "1.0.0",
  "description": "A type-safe REST API with Node.js and TypeScript",
  "main": "dist/server.js",
  "scripts": {
    "build": "npx tsc",
    "start": "node dist/server.js",
    "dev": "ts-node-dev --respawn --transpile-only src/server.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "dotenv": "^16.3.1",
    "express": "^4.18.2"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^20.10.5",
    "ts-node-dev": "^2.0.0",
    "typescript": "^5.3.3"
  }
}
tsconfig.json
json
{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "lib": ["es2018"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "sourceMap": true,
    
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "**/*.spec.ts"]
}

Key tsconfig.json Options Explained

  • target: Specifies the ECMAScript target version for the compiled JavaScript code. es2018 is a good balance for modern Node.js environments.
  • module: Specifies the module system for the generated JavaScript code. commonjs is standard for Node.js.
  • lib: Includes declaration files for built-in API support. es2018 is sufficient.
  • outDir: The output directory for compiled JavaScript files.
  • rootDir: The root directory of TypeScript source files.
  • strict: Enables a broad range of type-checking validation rules. Highly recommended for type-safety.
  • esModuleInterop: Enables compatibility between CommonJS and ES modules. Essential for importing modules like Express.
  • skipLibCheck: Skips type checking of declaration files, improving compilation speed.
  • forceConsistentCasingInFileNames: Ensures consistency in file naming across different operating systems.
  • declaration: Generates .d.ts declaration files, useful for library development.
  • sourceMap: Generates source map files for easier debugging of TypeScript code in development.

Defining Request and Response Types

The cornerstone of a type-safe API is defining explicit types for everything: your data entities, incoming request bodies, query parameters, and outgoing response data. This establishes a contract between your API and its consumers, and internally between your layers.

Let's define a simple User entity and types for its creation and update operations, along with a generic API Response structure.

src/types/user.ts
typescript
export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

export type CreateUserPayload = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
export type UpdateUserPayload = Partial<CreateUserPayload>;

export interface UserQueryParams {
  email?: string;
  name?: string;
  page?: string;
  limit?: string;
}
src/types/api.ts
typescript
export interface ApiResponse<T> {
  success: boolean;
  data?: T;
  message?: string;
  error?: string;
  statusCode: number;
}

export interface ErrorResponse {
  success: boolean;
  message: string;
  error: string;
  statusCode: number;
}

export interface PagedResponse<T> {
  items: T[];
  totalItems: number;
  page: number;
  limit: number;
}

Crafting Controllers: Request Handling and Validation

Controllers are the first point of contact for an incoming HTTP Request. Their primary responsibilities include:

  • Parsing Request Data: Extracting parameters, query strings, and body data.
  • Input Validation: Ensuring the incoming data adheres to expected types and constraints.
  • Delegating to Services: Calling the appropriate business logic layer.
  • Formatting Responses: Structuring the data returned from services into an HTTP Response.
  • Error Handling: Catching errors from services and formatting them into appropriate HTTP Error Responses.

Crucially, controllers should not contain business logic. They act as thin wrappers around service calls.

Keep Controllers Lean

Controllers should be as thin as possible. Their main job is to orchestrate the request and response flow, not to implement complex business rules. Delegate all business logic to services.

src/controllers/userController.ts
typescript
import { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid'; // For generating unique IDs
import { UserService } from '../services/userService';
import { CreateUserPayload, UpdateUserPayload, UserQueryParams } from '../types/user';
import { ApiResponse, ErrorResponse } from '../types/api';
import { ApiError } from '../utils/ApiError';

export class UserController {
  private userService: UserService;

  constructor(userService: UserService) {
    this.userService = userService;
  }

  public async createUser(
    req: Request<{}, ApiResponse<any>, CreateUserPayload>,
    res: Response<ApiResponse<any> | ErrorResponse>
  ): Promise<void> {
    try {
      const userData = req.body; // userData is automatically typed as CreateUserPayload
      if (!userData.name || !userData.email) {
        throw new ApiError(400, 'Name and email are required');
      }

      const newUser = await this.userService.createUser(userData);
      res.status(201).json({ success: true, data: newUser, statusCode: 201 });
    } catch (error: any) {
      if (error instanceof ApiError) {
        res.status(error.statusCode).json({ success: false, message: error.message, error: error.name, statusCode: error.statusCode });
      } else {
        res.status(500).json({ success: false, message: 'Internal Server Error', error: error.message, statusCode: 500 });
      }
    }
  }

  public async getUserById(
    req: Request<{ id: string }>, // req.params.id is typed as string
    res: Response<ApiResponse<any> | ErrorResponse>
  ): Promise<void> {
    try {
      const userId = req.params.id;
      const user = await this.userService.getUserById(userId);

      if (!user) {
        throw new ApiError(404, 'User not found');
      }

      res.status(200).json({ success: true, data: user, statusCode: 200 });
    } catch (error: any) {
      if (error instanceof ApiError) {
        res.status(error.statusCode).json({ success: false, message: error.message, error: error.name, statusCode: error.statusCode });
      } else {
        res.status(500).json({ success: false, message: 'Internal Server Error', error: error.message, statusCode: 500 });
      }
    }
  }

  public async getUsers(
    req: Request<{}, ApiResponse<any>, any, UserQueryParams>, // req.query is typed as UserQueryParams
    res: Response<ApiResponse<any> | ErrorResponse>
  ): Promise<void> {
    try {
      const queryParams = req.query;
      const users = await this.userService.getUsers(queryParams);

      res.status(200).json({ success: true, data: users, statusCode: 200 });
    } catch (error: any) {
      if (error instanceof ApiError) {
        res.status(error.statusCode).json({ success: false, message: error.message, error: error.name, statusCode: error.statusCode });
      } else {
        res.status(500).json({ success: false, message: 'Internal Server Error', error: error.message, statusCode: 500 });
      }
    }
  }

  public async updateUser(
    req: Request<{ id: string }, ApiResponse<any>, UpdateUserPayload>,
    res: Response<ApiResponse<any> | ErrorResponse>
  ): Promise<void> {
    try {
      const userId = req.params.id;
      const updateData = req.body;

      const updatedUser = await this.userService.updateUser(userId, updateData);

      if (!updatedUser) {
        throw new ApiError(404, 'User not found');
      }

      res.status(200).json({ success: true, data: updatedUser, statusCode: 200 });
    } catch (error: any) {
      if (error instanceof ApiError) {
        res.status(error.statusCode).json({ success: false, message: error.message, error: error.name, statusCode: error.statusCode });
      } else {
        res.status(500).json({ success: false, message: 'Internal Server Error', error: error.message, statusCode: 500 });
      }
    }
  }

  public async deleteUser(
    req: Request<{ id: string }>, 
    res: Response<ApiResponse<any> | ErrorResponse>
  ): Promise<void> {
    try {
      const userId = req.params.id;
      const deleted = await this.userService.deleteUser(userId);

      if (!deleted) {
        throw new ApiError(404, 'User not found');
      }

      res.status(204).json({ success: true, message: 'User deleted', statusCode: 204 });
    } catch (error: any) {
      if (error instanceof ApiError) {
        res.status(error.statusCode).json({ success: false, message: error.message, error: error.name, statusCode: error.statusCode });
      } else {
        res.status(500).json({ success: false, message: 'Internal Server Error', error: error.message, statusCode: 500 });
      }
    }
  }
}

Notice the use of generic types with Request and Response from Express:

  • Request<P, ResBody, ReqBody, ReqQuery>: Allows you to strongly type path parameters (P), response body (ResBody), request body (ReqBody), and query parameters (ReqQuery). This is crucial for catching type mismatches at compile time.
  • Response<ResBody>: Types the response body that your controller is expected to send.

This explicit typing ensures that when you access req.body, req.params, or req.query, TypeScript knows exactly what shape the data should take, providing intelligent autocompletion and compile-time checks.

Implementing Services: The Business Logic Core

Services encapsulate the business logic of your application. They are responsible for:

  • Executing Business Rules: Validating data beyond structural checks (e.g., uniqueness of email).
  • Data Persistence: Interacting with databases or external APIs.
  • Orchestrating Operations: Combining multiple data operations or external calls to fulfill a request.
  • Returning Typed Data: Always returning well-defined types, ensuring consistency for the calling Controller.

Services should be unaware of HTTP concepts (like requests, responses, status codes). They simply take typed input, perform operations, and return typed output or throw specific errors.

src/services/userService.ts
typescript
import { User, CreateUserPayload, UpdateUserPayload, UserQueryParams } from '../types/user';
import { ApiError } from '../utils/ApiError';
import { v4 as uuidv4 } from 'uuid';

// This would typically be a database client or ORM interaction
// For simplicity, we're using an in-memory array
let usersDB: User[] = [];

export class UserService {
  public async createUser(userData: CreateUserPayload): Promise<User> {
    // Simulate database check for existing user
    if (usersDB.some(user => user.email === userData.email)) {
      throw new ApiError(409, 'User with this email already exists');
    }

    const newUser: User = {
      id: uuidv4(),
      name: userData.name,
      email: userData.email,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    usersDB.push(newUser);
    return newUser;
  }

  public async getUserById(id: string): Promise<User | undefined> {
    return usersDB.find(user => user.id === id);
  }

  public async getUsers(queryParams: UserQueryParams): Promise<User[]> {
    let filteredUsers = usersDB;

    if (queryParams.email) {
      filteredUsers = filteredUsers.filter(user => user.email.includes(queryParams.email!));
    }
    if (queryParams.name) {
      filteredUsers = filteredUsers.filter(user => user.name.includes(queryParams.name!));
    }
    
    // Implement pagination logic here if needed, using queryParams.page and queryParams.limit

    return filteredUsers;
  }

  public async updateUser(id: string, updateData: UpdateUserPayload): Promise<User | undefined> {
    const userIndex = usersDB.findIndex(user => user.id === id);

    if (userIndex === -1) {
      return undefined; // User not found
    }

    const existingUser = usersDB[userIndex];
    const updatedUser: User = {
      ...existingUser,
      ...updateData,
      updatedAt: new Date(),
    };
    usersDB[userIndex] = updatedUser;
    return updatedUser;
  }

  public async deleteUser(id: string): Promise<boolean> {
    const initialLength = usersDB.length;
    usersDB = usersDB.filter(user => user.id !== id);
    return usersDB.length < initialLength;
  }

  // For testing/resetting in-memory DB
  public clearUsers(): void {
    usersDB = [];
  }
}

Service Layer Isolation

Never expose HTTP-specific objects (like `req`, `res`) to your services. Services should operate purely on data and business logic, making them reusable and easily testable outside the context of a web request.

Routing: Connecting Paths to Controllers

With our controllers and services defined, the next step is to set up routes that map HTTP methods and paths to the appropriate controller methods. We'll use Express's Router for this.

src/routes/userRoutes.ts
typescript
import { Router } from 'express';
import { UserController } from '../controllers/userController';
import { UserService } from '../services/userService';

// Instantiate service and controller (dependency injection)
const userService = new UserService();
const userController = new UserController(userService);

const router = Router();

// Define user routes
router.post('/', userController.createUser.bind(userController));
router.get('/:id', userController.getUserById.bind(userController));
router.get('/', userController.getUsers.bind(userController));
router.put('/:id', userController.updateUser.bind(userController));
router.delete('/:id', userController.deleteUser.bind(userController));

export { router as userRoutes };

The .bind(userController) is crucial here to ensure that this context inside the controller methods refers to the userController instance, not the Express router context.

Type-Safe Error Handling

Graceful and type-safe error handling is vital for any production API. Instead of throwing generic Error objects, we can define custom error classes that carry specific information, making it easier for controllers to return meaningful HTTP Status Codes and error messages.

src/utils/ApiError.ts
typescript
export class ApiError extends Error {
  public statusCode: number;
  public isOperational: boolean; // Indicates if error is predictable/operational or a programming error

  constructor(statusCode: number, message: string, isOperational: boolean = true) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    this.name = this.constructor.name; // Capture class name as error name

    // Restore prototype chain
    Object.setPrototypeOf(this, new.target.prototype);

    // Capture stack trace (V8 specific)
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

This ApiError class allows us to throw specific errors from our services with an associated HTTP Status Code and a clear message. Controllers can then catch these specific errors and format the response accordingly, as demonstrated in userController.ts.

Global Error Middleware

For production APIs, consider implementing a global error handling middleware in Express. This catches any unhandled errors, logs them, and sends a standardized error response, preventing your application from crashing and ensuring consistent error formatting. For simplicity, we handled errors within controllers here.

Bringing It All Together: The Main Application File

Finally, let's create our main application entry point, src/server.ts, where we'll set up the Express app, load environment variables, and start the server.

src/server.ts
typescript
import dotenv from 'dotenv';
dotenv.config(); // Load environment variables from .env file

import express, { Request, Response, NextFunction } from 'express';
import { userRoutes } from './routes/userRoutes';
import { ApiError } from './utils/ApiError';

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware to parse JSON bodies
app.use(express.json());

// Routes
app.use('/api/users', userRoutes);

// Catch-all for undefined routes (404 Not Found)
app.use((req: Request, res: Response, next: NextFunction) => {
  next(new ApiError(404, `Cannot find ${req.originalUrl} on this server!`));
});

// Global Error Handling Middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  let error = err;

  if (!(error instanceof ApiError)) {
    // For unhandled non-ApiError errors, convert to a generic 500 error
    error = new ApiError(500, 'Something went wrong!', false);
  }

  const apiError = error as ApiError;
  res.status(apiError.statusCode).json({
    success: false,
    message: apiError.message,
    error: apiError.name,
    statusCode: apiError.statusCode,
    // In production, avoid sending detailed error info for security
    // stack: process.env.NODE_ENV === 'development' ? apiError.stack : undefined,
  });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  console.log(`Access at http://localhost:${PORT}/api/users`);
});

And don't forget your .env file:

env
env
PORT=3000
NODE_ENV=development

To run the application in development mode with auto-reloads:

bash
bash
npm run dev

To build and run in production mode:

bash
bash
npm run build
npm start

Project Structure Overview

To summarize, here's a recommended project structure for clarity and maintainability:

  • src/:
    • controllers/: Handles HTTP requests, delegates to services, and sends responses.
    • services/: Contains core business logic, interacts with data sources.
    • routes/: Defines API endpoints and maps them to controller methods.
    • types/: Houses all custom TypeScript interfaces and types for entities, requests, and responses.
    • utils/: Utility functions, including custom error classes (ApiError.ts).
    • server.ts: The main application entry point, setting up Express and starting the server.

This structure ensures a clean separation of concerns, making your codebase easy to navigate, extend, and test.

Conclusion: The Power of Type-Safe APIs

By diligently applying TypeScript throughout your Node.js REST API, you transform a potentially chaotic JavaScript backend into a predictable, robust, and maintainable system. We've covered the entire journey, from setting up your project and configuring tsconfig.json to designing type-safe controllers and services, defining explicit request/response types, and implementing structured error handling.

The benefits extend beyond just catching bugs; type-safety provides an inherent form of documentation, enhances developer tooling with intelligent autocompletion, and fosters confidence in refactoring. Embrace TypeScript in your backend development, and experience a significant leap in the quality and longevity of your APIs. Your future self, and your team, will thank you.

Enjoyed this deep dive?

Share:
Tags:TypeScriptNode.jsREST APIType-SafetyExpress.jsBackend Development

Discussion & Feedback

0 thoughts shared by the community

💬

Loading discussion thread...