Building a RESTful API with Node.js, Express, and TypeScript
Building a RESTful API with Node.js, Express, and TypeScript
Building modern web applications often requires a robust backend API. In this comprehensive guide, I'll walk through the process of creating a production-ready RESTful API using Node.js, Express, and TypeScript. This powerful combination provides the flexibility of JavaScript with the safety and maintainability of static typing.
Table of Contents
- Introduction
- Prerequisites
- Project Setup
- Core Application Structure
- Database Integration
- API Routes and Controllers
- Authentication and Authorization
- Middleware Implementation
- Error Handling
- Validation
- Testing
- Documentation
- Deployment
- Conclusion
Introduction
RESTful APIs (Representational State Transfer) have become the industry standard for building scalable web services. They provide a stateless, client-server architecture that's perfect for modern web applications. By combining:
- Node.js - A JavaScript runtime built on Chrome's V8 JavaScript engine
- Express - A minimal and flexible Node.js web application framework
- TypeScript - A strongly typed programming language that builds on JavaScript
We can create APIs that are not only performant but also maintainable and scalable.
Prerequisites
Before we begin, make sure you have the following installed:
- Node.js (v14 or newer)
- npm or yarn package manager
- A code editor (VS Code recommended for TypeScript integration)
- Basic understanding of JavaScript/TypeScript
- Familiarity with RESTful principles
Project Setup
Let's start by setting up our project from scratch.
Initialize the Project
# Create project directory
mkdir express-typescript-api
cd express-typescript-api
# Initialize npm package
npm init -y
# Install core dependencies
npm install express cors helmet dotenv mongoose
# Install development dependencies
npm install -D typescript @types/node @types/express @types/cors ts-node-dev
Configure TypeScript
Create a tsconfig.json
file in the root of your project:
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}
Update package.json Scripts
Add these scripts to your package.json
file:
"scripts": {
"start": "node dist/server.js",
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"lint": "eslint . --ext .ts",
"test": "jest"
}
Create Project Structure
A well-organized project structure is essential for maintainability. Here's a recommended structure:
express-typescript-api/
├── src/
│ ├── config/ # Configuration files
│ ├── controllers/ # Request handlers
│ ├── middleware/ # Custom middleware
│ ├── models/ # Database models
│ ├── routes/ # API routes
│ ├── services/ # Business logic
│ ├── types/ # TypeScript types/interfaces
│ ├── utils/ # Utility functions
│ ├── app.ts # Express app setup
│ └── server.ts # Server entry point
├── .env # Environment variables
├── .gitignore
├── package.json
└── tsconfig.json
Let's create these directories:
mkdir -p src/{config,controllers,middleware,models,routes,services,types,utils}
touch src/app.ts src/server.ts .env .gitignore
Core Application Structure
Environment Variables (.env)
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/my_api
JWT_SECRET=your_jwt_secret
JWT_EXPIRES_IN=90d
Add .env
to your .gitignore
file to ensure sensitive information isn't committed to version control.
Entry Point (server.ts)
import app from "./app";
import dotenv from "dotenv";
// Load environment variables
dotenv.config();
const PORT = process.env.PORT || 3000;
// Start the server
app.listen(PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});
Express Application Setup (app.ts)
import express, { Application, Request, Response, NextFunction } from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import { connectDB } from "./config/database";
import { errorHandler } from "./middleware/errorHandler";
import userRoutes from "./routes/userRoutes";
import authRoutes from "./routes/authRoutes";
// Create Express application
const app: Application = express();
// Connect to database
connectDB();
// Middleware
app.use(helmet()); // Security headers
app.use(cors()); // Enable CORS
app.use(morgan("dev")); // Logging
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: false })); // Parse URL-encoded bodies
// Health check endpoint
app.get("/health", (req: Request, res: Response) => {
res.status(200).json({ status: "ok" });
});
// API Routes
app.use("/api/users", userRoutes);
app.use("/api/auth", authRoutes);
// Not found middleware
app.use((req: Request, res: Response) => {
res.status(404).json({
message: "Resource not found",
});
});
// Error handling middleware
app.use(errorHandler);
export default app;
Database Integration
Database Configuration (config/database.ts)
import mongoose from "mongoose";
import dotenv from "dotenv";
dotenv.config();
export const connectDB = async (): Promise<void> => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI as string);
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error(
`Error: ${
error instanceof Error ? error.message : "Unknown error occurred"
}`,
);
process.exit(1);
}
};
Model Definition (models/User.ts)
import mongoose, { Schema, Document } from "mongoose";
import bcrypt from "bcryptjs";
export interface IUser extends Document {
name: string;
email: string;
password: string;
role: "user" | "admin";
createdAt: Date;
updatedAt: Date;
comparePassword(candidatePassword: string): Promise<boolean>;
}
const UserSchema: Schema = new Schema(
{
name: {
type: String,
required: [true, "Please provide a name"],
trim: true,
maxlength: [50, "Name cannot be more than 50 characters"],
},
email: {
type: String,
required: [true, "Please provide an email"],
unique: true,
trim: true,
lowercase: true,
match: [
/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/,
"Please provide a valid email",
],
},
password: {
type: String,
required: [true, "Please provide a password"],
minlength: [6, "Password must be at least 6 characters"],
select: false,
},
role: {
type: String,
enum: ["user", "admin"],
default: "user",
},
},
{
timestamps: true,
},
);
// Hash password before saving
UserSchema.pre("save", async function (next) {
if (!this.isModified("password")) {
return next();
}
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Compare entered password with stored hash
UserSchema.methods.comparePassword = async function (
candidatePassword: string,
): Promise<boolean> {
return await bcrypt.compare(candidatePassword, this.password);
};
export default mongoose.model<IUser>("User", UserSchema);
API Routes and Controllers
Types Definition (types/express.d.ts)
import { Express } from "express";
import { IUser } from "../models/User";
declare global {
namespace Express {
interface Request {
user?: IUser;
}
}
}
Authentication Controller (controllers/authController.ts)
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import User, { IUser } from "../models/User";
import { AppError } from "../utils/appError";
import { catchAsync } from "../utils/catchAsync";
// Create and send JWT token
const signToken = (id: string): string => {
return jwt.sign({ id }, process.env.JWT_SECRET as string, {
expiresIn: process.env.JWT_EXPIRES_IN,
});
};
// Register a new user
export const register = catchAsync(async (req: Request, res: Response) => {
const { name, email, password } = req.body;
// Check if user already exists
const userExists = await User.findOne({ email });
if (userExists) {
throw new AppError("User already exists", 400);
}
// Create user
const user = await User.create({
name,
email,
password,
});
// Generate JWT token
const token = signToken(user._id);
// Send response
res.status(201).json({
success: true,
token,
user: {
id: user._id,
name: user.name,
email: user.email,
role: user.role,
},
});
});
// Login user
export const login = catchAsync(
async (req: Request, res: Response, next: NextFunction) => {
const { email, password } = req.body;
// Check if email and password are provided
if (!email || !password) {
return next(new AppError("Please provide email and password", 400));
}
// Find user
const user = await User.findOne({ email }).select("+password");
if (!user) {
return next(new AppError("Invalid credentials", 401));
}
// Check if password is correct
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return next(new AppError("Invalid credentials", 401));
}
// Generate JWT token
const token = signToken(user._id);
// Send response
res.status(200).json({
success: true,
token,
user: {
id: user._id,
name: user.name,
email: user.email,
role: user.role,
},
});
},
);
User Controller (controllers/userController.ts)
import { Request, Response } from "express";
import User from "../models/User";
import { catchAsync } from "../utils/catchAsync";
import { AppError } from "../utils/appError";
// Get all users (admin only)
export const getAllUsers = catchAsync(async (req: Request, res: Response) => {
const users = await User.find().select("-password");
res.status(200).json({
success: true,
count: users.length,
data: users,
});
});
// Get single user by ID
export const getUserById = catchAsync(async (req: Request, res: Response) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new AppError("User not found", 404);
}
res.status(200).json({
success: true,
data: user,
});
});
// Update user
export const updateUser = catchAsync(async (req: Request, res: Response) => {
// Prevent password update through this route
if (req.body.password) {
throw new AppError("This route is not for password updates", 400);
}
const user = await User.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!user) {
throw new AppError("User not found", 404);
}
res.status(200).json({
success: true,
data: user,
});
});
// Delete user
export const deleteUser = catchAsync(async (req: Request, res: Response) => {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) {
throw new AppError("User not found", 404);
}
res.status(204).json({
success: true,
data: null,
});
});
Routes Definition
Authentication Routes (routes/authRoutes.ts)
import express from "express";
import { register, login } from "../controllers/authController";
const router = express.Router();
router.post("/register", register);
router.post("/login", login);
export default router;
User Routes (routes/userRoutes.ts)
import express from "express";
import {
getAllUsers,
getUserById,
updateUser,
deleteUser,
} from "../controllers/userController";
import { protect, restrictTo } from "../middleware/authMiddleware";
const router = express.Router();
// Protect all routes after this middleware
router.use(protect);
// Routes restricted to admin
router.get("/", restrictTo("admin"), getAllUsers);
// User routes (for current user or admin)
router.route("/:id").get(getUserById).patch(updateUser).delete(deleteUser);
export default router;
Authentication and Authorization
Authentication Middleware (middleware/authMiddleware.ts)
import { Request, Response, NextFunction } from "express";
import jwt, { JwtPayload } from "jsonwebtoken";
import { catchAsync } from "../utils/catchAsync";
import { AppError } from "../utils/appError";
import User, { IUser } from "../models/User";
// Protect routes
export const protect = catchAsync(
async (req: Request, res: Response, next: NextFunction) => {
// 1) Get token from header
let token;
if (
req.headers.authorization &&
req.headers.authorization.startsWith("Bearer")
) {
token = req.headers.authorization.split(" ")[1];
}
if (!token) {
return next(
new AppError("You are not logged in. Please log in to get access", 401),
);
}
// 2) Verify token
const decoded = jwt.verify(
token,
process.env.JWT_SECRET as string,
) as JwtPayload;
// 3) Check if user still exists
const user = await User.findById(decoded.id);
if (!user) {
return next(
new AppError("The user belonging to this token no longer exists", 401),
);
}
// 4) Add user to request
req.user = user;
next();
},
);
// Restrict to certain roles
export const restrictTo = (...roles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return next(new AppError("User not found", 401));
}
if (!roles.includes(req.user.role)) {
return next(
new AppError("You do not have permission to perform this action", 403),
);
}
next();
};
};
Middleware Implementation
Error Handling Middleware (middleware/errorHandler.ts)
import { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/appError";
// Development error response
const sendErrorDev = (err: AppError, res: Response) => {
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack,
});
};
// Production error response
const sendErrorProd = (err: AppError, res: Response) => {
// Operational, trusted error: send message to client
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
});
} else {
// Programming or other unknown error: don't leak error details
console.error("ERROR 💥", err);
res.status(500).json({
status: "error",
message: "Something went very wrong",
});
}
};
// Handle MongoDB duplicate key errors
const handleDuplicateFieldsDB = (err: any): AppError => {
const value = err.errmsg.match(/(["'])(\\?.)*?\1/)[0];
const message = `Duplicate field value: ${value}. Please use another value`;
return new AppError(message, 400);
};
// Handle Mongoose validation errors
const handleValidationErrorDB = (err: any): AppError => {
const errors = Object.values(err.errors).map((el: any) => el.message);
const message = `Invalid input data. ${errors.join(". ")}`;
return new AppError(message, 400);
};
// Handle JWT errors
const handleJWTError = (): AppError => {
return new AppError("Invalid token. Please log in again", 401);
};
const handleJWTExpiredError = (): AppError => {
return new AppError("Your token has expired. Please log in again", 401);
};
// Main error handler
export const errorHandler = (
err: any,
req: Request,
res: Response,
next: NextFunction,
) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || "error";
if (process.env.NODE_ENV === "development") {
sendErrorDev(err, res);
} else if (process.env.NODE_ENV === "production") {
let error = { ...err };
error.message = err.message;
if (error.code === 11000) error = handleDuplicateFieldsDB(error);
if (error.name === "ValidationError")
error = handleValidationErrorDB(error);
if (error.name === "JsonWebTokenError") error = handleJWTError();
if (error.name === "TokenExpiredError") error = handleJWTExpiredError();
sendErrorProd(error, res);
}
};
Utility Functions
Error Class (utils/appError.ts)
export class AppError extends Error {
statusCode: number;
status: string;
isOperational: boolean;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith("4") ? "fail" : "error";
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
Async Handler (utils/catchAsync.ts)
import { Request, Response, NextFunction } from "express";
export const catchAsync = (fn: Function) => {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
};
Validation
Request Validation with Express-Validator (middleware/validationMiddleware.ts)
First, install express-validator:
npm install express-validator
Then create the validation middleware:
import { Request, Response, NextFunction } from "express";
import { validationResult, ValidationChain } from "express-validator";
import { AppError } from "../utils/appError";
export const validate = (validations: ValidationChain[]) => {
return async (req: Request, res: Response, next: NextFunction) => {
await Promise.all(validations.map((validation) => validation.run(req)));
const errors = validationResult(req);
if (errors.isEmpty()) {
return next();
}
const extractedErrors: string[] = [];
errors.array().map((err) => extractedErrors.push(err.msg));
return next(new AppError(extractedErrors.join(". "), 400));
};
};
User Validation Schemas (validators/userValidators.ts)
import { body } from "express-validator";
export const registerValidator = [
body("name")
.notEmpty()
.withMessage("Name is required")
.isLength({ min: 2, max: 50 })
.withMessage("Name must be between 2 and 50 characters"),
body("email")
.notEmpty()
.withMessage("Email is required")
.isEmail()
.withMessage("Must be a valid email address"),
body("password")
.notEmpty()
.withMessage("Password is required")
.isLength({ min: 6 })
.withMessage("Password must be at least 6 characters long"),
];
export const loginValidator = [
body("email")
.notEmpty()
.withMessage("Email is required")
.isEmail()
.withMessage("Must be a valid email address"),
body("password").notEmpty().withMessage("Password is required"),
];
Applying Validation to Routes
Update the auth routes to include validation:
import express from "express";
import { register, login } from "../controllers/authController";
import { validate } from "../middleware/validationMiddleware";
import {
registerValidator,
loginValidator,
} from "../validators/userValidators";
const router = express.Router();
router.post("/register", validate(registerValidator), register);
router.post("/login", validate(loginValidator), login);
export default router;
Testing
Testing is crucial for maintaining a robust API. Let's set up Jest for testing:
npm install -D jest ts-jest supertest @types/jest @types/supertest
Create a jest configuration file jest.config.js
:
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/*.test.ts"],
verbose: true,
forceExit: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true,
};
Testing Setup (tests/setup.ts)
import mongoose from "mongoose";
import dotenv from "dotenv";
dotenv.config({ path: ".env.test" });
// Connect to test database before tests
beforeAll(async () => {
await mongoose.connect(process.env.MONGODB_URI as string);
});
// Clear all test data after each test
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
const collection = collections[key];
await collection.deleteMany({});
}
});
// Close database connection after all tests
afterAll(async () => {
await mongoose.connection.close();
});
Sample Test (tests/auth.test.ts)
import request from "supertest";
import app from "../src/app";
import User from "../src/models/User";
describe("Auth Routes", () => {
describe("POST /api/auth/register", () => {
it("should register a new user", async () => {
const res = await request(app).post("/api/auth/register").send({
name: "Test User",
email: "test@example.com",
password: "password123",
});
expect(res.status).toBe(201);
expect(res.body).toHaveProperty("token");
expect(res.body.user).toHaveProperty("id");
expect(res.body.user.email).toBe("test@example.com");
});
it("should not register a user with existing email", async () => {
// Create a user first
await User.create({
name: "Existing User",
email: "existing@example.com",
password: "password123",
});
// Try to register with the same email
const res = await request(app).post("/api/auth/register").send({
name: "Another User",
email: "existing@example.com",
password: "password123",
});
expect(res.status).toBe(400);
expect(res.body).toHaveProperty("message", "User already exists");
});
});
describe("POST /api/auth/login", () => {
beforeEach(async () => {
await User.create({
name: "Login Test User",
email: "login@example.com",
password: "password123",
});
});
it("should login an existing user", async () => {
const res = await request(app).post("/api/auth/login").send({
email: "login@example.com",
password: "password123",
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("token");
expect(res.body.user).toHaveProperty("email", "login@example.com");
});
it("should not login with incorrect password", async () => {
const res = await request(app).post("/api/auth/login").send({
email: "login@example.com",
password: "wrongpassword",
});
expect(res.status).toBe(401);
expect(res.body).toHaveProperty("message", "Invalid credentials");
});
});
});
Documentation
API documentation is essential for developers who will use your API. Let's set up Swagger:
npm install swagger-jsdoc swagger-ui-express
npm install -D @types/swagger-jsdoc @types/swagger-ui-express
Create a swagger config file (config/swagger.ts):
import swaggerJSDoc from "swagger-jsdoc";
const options = {
definition: {
openapi: "3.0.0",
info: {
title: "Express TypeScript API",
version: "1.0.0",
description: "A simple REST API built with Express and TypeScript",
},
servers: [
{
url: "http://localhost:3000/api",
description: "Development server",
},
],
},
apis: ["./src/routes/*.ts", "./src/models/*.ts"],
};
export const specs = swaggerJSDoc(options);
Add Swagger documentation to your app.ts:
import swaggerUi from "swagger-ui-express";
import { specs } from "./config/swagger";
// Swagger API Documentation
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(specs));
Document your routes with JSDoc comments. For example:
/**
* @swagger
* /users:
* get:
* summary: Returns a list of users
* description: Retrieves a list of all users (admin only)
* tags: [Users]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: An array of user objects
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* count:
* type: integer
* data:
* type: array
* items:
* $ref: '#/components/schemas/User'
* 401:
* description: Not authorized
* 403:
* description: Forbidden
*/
router.get("/", restrictTo("admin"), getAllUsers);
Deployment
Preparing for Production
- Build the project:
npm run build
-
Set up environment variables for production
-
Configure a process manager like PM2:
npm install -g pm2
pm2 start dist/server.js --name "api"
Docker Deployment
Create a Dockerfile
:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
Create a docker-compose.yml
file:
version: "3"
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- MONGODB_URI=mongodb://mongo:27017/api
- JWT_SECRET=your_secret_key
- JWT_EXPIRES_IN=90d
depends_on:
- mongo
restart: always
mongo:
image: mongo:latest
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
Conclusion
We've built a comprehensive RESTful API using Node.js, Express, and TypeScript that's ready for production. This setup provides:
- Type safety with TypeScript
- Clean code structure
- Robust error handling
- Authentication and authorization
- Input validation
- API documentation
- Testing framework
- Deployment configuration
This architecture is scalable and can be extended with additional features like rate limiting, caching, logging, and more sophisticated business logic as your application grows.
By following the principles outlined in this guide, you'll have a solid foundation for building reliable, maintainable, and secure API services that can evolve with your project requirements.
Remember that security is an ongoing concern - regularly update dependencies, review authentication mechanisms, and implement additional security measures like CSRF protection and security headers as needed.
Happy coding!