Building a Scalable Express Application with TypeScript

Table of contents

No heading

No headings in the article.

Introduction: In this guide, we'll build a scalable Express application using TypeScript. We'll cover setting up a modular structure, handling errors gracefully, and validating requests with Zod.

Step 1: Setting Up the Project

First, create a new directory and initialize a Node.js project:

mkdir my-express-app
cd my-express-app
npm init -y

Install the necessary dependencies:

npm install express mongoose cors dotenv http-status zod
npm install --save-dev typescript @types/express @types/node @types/cors @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier prettier nodemon

Create the TypeScript configuration file (tsconfig.json):

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

Step 2: Project Structure

Create the following project structure:

my-express-app/
│
├── src/
│   ├── app/
│   │   ├── builder/
│   │   │   └── QueryBuilder.ts
│   │   ├── config/
│   │   │   └── index.ts
│   │   ├── errors/
│   │   │   ├── AppError.ts
│   │   │   ├── handleCastError.ts
│   │   │   ├── handleDuplicateError.ts
│   │   │   ├── handleValidationError.ts
│   │   │   └── handleZodError.ts
│   │   ├── interface/
│   │   │   ├── error.ts
│   │   │   └── index.d.ts
│   │   ├── middleware/
│   │   │   ├── auth.ts
│   │   │   ├── globalErrorhandler.ts
│   │   │   ├── notFound.ts
│   │   │   └── validateRequest.ts
│   │   ├── modules/
│   │   │   ├── User/
│   │   │   │   ├── UserConstant.ts
│   │   │   │   ├── UserController.ts
│   │   │   │   ├── UserInterface.ts
│   │   │   │   ├── UserModel.ts
│   │   │   │   ├── UserRoute.ts
│   │   │   │   └── UserValidation.ts
│   │   ├── routes/
│   │   │   └── index.ts
│   │   ├── utils/
│   │   │   ├── catchAsync.ts
│   │   │   └── sendResponse.ts
│   ├── app.ts
│   └── server.ts

Step 3: Configuring the Server

Create src/server.ts:

import { Server } from 'http';
import mongoose from 'mongoose';
import app from './app';
import config from './app/config';

let server: Server;

async function main() {
  try {
    await mongoose.connect(config.database_url as string);

    server = app.listen(config.port, () => {
      console.log(`App is listening on port ${config.port}`);
    });
  } catch (err) {
    console.log(err);
  }
}

main();

process.on('unhandledRejection', () => {
  console.log('Unhandled rejection detected, shutting down...');
  if (server) {
    server.close(() => {
      process.exit(1);
    });
  }
  process.exit(1);
});

process.on('uncaughtException', () => {
  console.log('Uncaught exception detected, shutting down...');
  process.exit(1);
});

Create src/app.ts:

import cookieParser from 'cookie-parser';
import cors from 'cors';
import express, { Application } from 'express';
import globalErrorHandler from './app/middlewares/globalErrorhandler';
import notFound from './app/middlewares/notFound';
import router from './app/routes';

const app: Application = express();

app.use(express.json());
app.use(cookieParser());
app.use(cors({ origin: ['http://localhost:5173'] }));

app.use('/api/v1', router);
app.use(globalErrorHandler);
app.use(notFound);

export default app;

Step 4: Configuration

Create src/app/config/index.ts:

import dotenv from 'dotenv';
import path from 'path';

dotenv.config({ path: path.join(process.cwd(), '.env') });

export default {
  NODE_ENV: process.env.NODE_ENV,
  port: process.env.PORT,
  database_url: process.env.DATABASE_URL,
  bcrypt_salt_rounds: process.env.BCRYPT_SALT_ROUNDS,
  default_password: process.env.DEFAULT_PASSWORD,
  jwt_access_secret: process.env.JWT_ACCESS_SECRET,
  jwt_refresh_secret: process.env.JWT_REFRESH_SECRET,
  jwt_access_expired_in: process.env.JWT_ACCESS_EXPIRED_IN,
  jwt_refresh_expired_in: process.env.JWT_REFRESH_EXPIRED_IN,
  reset_pass_uri_link: process.env.RESET_PASS_URI_LINK,
  super_admin_password: process.env.SUPER_ADMIN_PASSWORD,
};

Step 5: Error Handling

Create src/app/errors/AppError.ts:

class AppError extends Error {
  public statusCode: number;

  constructor(statusCode: number, message: string, stack = '') {
    super(message);
    this.statusCode = statusCode;

    if (stack) {
      this.stack = stack;
    } else {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

export default AppError;

Create src/app/errors/handleCastError.ts:

const handleCastError = (err: any) => {
  const message = `Invalid ${err.path}: ${err.value}.`;
  return {
    statusCode: 400,
    message,
    errorSource: [{ path: err.path, message }],
  };
};

export default handleCastError;

Create src/app/errors/handleDuplicateError.ts:

const handleDuplicateError = (err: any) => {
  const message = `Duplicate field value: ${err.keyValue}. Please use another value!`;
  return {
    statusCode: 400,
    message,
    errorSource: [{ path: Object.keys(err.keyValue)[0], message }],
  };
};

export default handleDuplicateError;

Create src/app/errors/handleValidationError.ts:

import { Error } from 'mongoose';

const handleValidationError = (err: Error.ValidationError) => {
  const errors = Object.values(err.errors).map(el => {
    return { path: el.path, message: el.message };
  });

  return {
    statusCode: 400,
    message: 'Invalid input data',
    errorSource: errors,
  };
};

export default handleValidationError;

Create src/app/errors/handleZodError.ts:

import { ZodError } from 'zod';

const handleZodError = (err: ZodError) => {
  const errors = err.errors.map(el => {
    return { path: el.path.join('.'), message: el.message };
  });

  return {
    statusCode: 400,
    message: 'Invalid input data',
    errorSource: errors,
  };
};

export default handleZodError;

Step 6: Middleware

Create src/app/middlewares/globalErrorhandler.ts:

import { ErrorRequestHandler } from 'express';
import { ZodError } from 'zod';
import config from '../config';
import handleValidationError from '../errors/handleValidationError';
import handleZodError from '../errors/handleZodError';
import handleCastError from '../errors/handleCastError';
import handleDuplicateError from '../errors/handleDuplicateError';

const globalErrorHandler: ErrorRequestHandler = (err, req, res, next) => {
  let statusCode = err.statusCode || 500;
  let message = err.message || 'Something went wrong!';
  let errorSource = [{ path: '', message: 'something went wrong' }];

  if (err instanceof ZodError) {
    const simplifiedError = handleZodError(err);
    statusCode = simplifiedError.statusCode;
    message = simplifiedError.message;
    errorSource = simplifiedError.errorSource;
  } else if (err.name === 'ValidationError') {
    const simplifiedError = handleValidationError(err);
    statusCode = simplifiedError.statusCode;
    message = simplifiedError.message;
    errorSource = simplifiedError.errorSource;
  } else if (err.name === 'CastError') {
    const simplifiedError = handleCastError(err);
    statusCode = simplifiedError.statusCode;
    message = simplifiedError.message;
    errorSource = simplifiedError.errorSource;
  } else if (err.code === 11000) {
    const simplifiedError = handleDuplicateError(err);
    statusCode = simplifiedError.statusCode;
    message = simplifiedError

.message;
    errorSource = simplifiedError.errorSource;
  } else if (err instanceof Error) {
    message = err.message;
    errorSource = [{ path: '', message: err.message }];
  }

  if (config.NODE_ENV === 'development') {
    console.log('Error:', err);
  }

  res.status(statusCode).json({
    success: false,
    message,
    errorSource,
  });

  next();
};

export default globalErrorHandler;

Create src/app/middlewares/notFound.ts:

import { Request, Response, NextFunction } from 'express';

const notFound = (req: Request, res: Response, next: NextFunction) => {
  res.status(404).json({
    success: false,
    message: 'Route not found',
  });
};

export default notFound;

Create src/app/middlewares/validateRequest.ts:

import { NextFunction, Request, Response } from 'express';
import { AnyZodObject } from 'zod';

const validateRequest = (schema: AnyZodObject) => {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (error) {
      next(error);
    }
  };
};

export default validateRequest;

Step 7: User Module

Create src/app/modules/User/UserController.ts:

import { Request, Response, NextFunction } from 'express';
import catchAsync from '../../utils/catchAsync';
import sendResponse from '../../utils/sendResponse';
import { UserService } from './UserService';

export const UserController = {
  createUser: catchAsync(async (req: Request, res: Response, next: NextFunction) => {
    const user = await UserService.createUser(req.body);
    sendResponse(res, {
      statusCode: 201,
      success: true,
      message: 'User created successfully',
      data: user,
    });
  }),
};

Create src/app/modules/User/UserService.ts:

import { User } from './UserModel';

export const UserService = {
  createUser: async (userData: any) => {
    const user = new User(userData);
    await user.save();
    return user;
  },
};

Create src/app/modules/User/UserModel.ts:

import { Schema, model } from 'mongoose';

const userSchema = new Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
});

export const User = model('User', userSchema);

Create src/app/modules/User/UserRoute.ts:

import express from 'express';
import { UserController } from './UserController';
import validateRequest from '../../middlewares/validateRequest';
import { createUserSchema } from './UserValidation';

const router = express.Router();

router.post('/', validateRequest(createUserSchema), UserController.createUser);

export default router;

Create src/app/modules/User/UserValidation.ts:

import { z } from 'zod';

export const createUserSchema = z.object({
  body: z.object({
    name: z.string().nonempty({ message: 'Name is required' }),
    email: z.string().email({ message: 'Invalid email address' }),
    password: z.string().min(6, { message: 'Password must be at least 6 characters long' }),
  }),
});

Step 8: Routes

Create src/app/routes/index.ts:

import { Router } from 'express';
import { UserRoutes } from '../modules/user/user.routes';
import { AuthRoutes } from '../modules/auth/auth.routes';

const router = Router();

const moduleRoutes = [
  {
    path: '/users',
    route: UserRoutes,
  },
  {
    path: '/auth',
    route: AuthRoutes,
  },
];

moduleRoutes.forEach(route => router.use(route.path, route.route));

export default router;

Step 9: Utility Functions

Create src/app/utils/catchAsync.ts:

import { Request, Response, NextFunction } from 'express';

const catchAsync = (fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) => {
  return (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch(next);
  };
};

export default catchAsync;

Create src/app/utils/sendResponse.ts:

import { Response } from 'express';

const sendResponse = (res: Response, { statusCode, success, message, data }: any) => {
  res.status(statusCode).json({
    success,
    message,
    data,
  });
};

export default sendResponse;

Conclusion: You now have a basic, scalable Express application structure using TypeScript. This setup includes request validation, error handling, and a user module with basic CRUD operations. This modular approach ensures that your application can scale and maintain readability as it grows.

Feel free to customize and expand upon this structure to suit the needs of your project.


This blog post covers setting up a scalable Express application with TypeScript, following the code snippets and folder structure you provided, excluding the configuration files and node_modules directory.