Back to blog

Phase 3: Backend Development with TypeScript

typescriptnodejsbackendapiexpress
Phase 3: Backend Development with TypeScript

Welcome to Phase 3

You've mastered TypeScript fundamentals and built type-safe React applications. Now it's time to build the backend that powers those frontends.

In this phase, you'll learn how to create production-ready REST and GraphQL APIs using Node.js 22+, Express.js, and TypeScript. You'll connect to databases with Prisma, implement authentication, handle errors gracefully, and deploy to production—all with full type safety.

Backend development with TypeScript transforms the chaotic world of Node.js into a structured, predictable environment where your IDE catches bugs before they reach production.

Time commitment: 3 weeks, 1-2 hours daily
Prerequisites: Phase 1 and Phase 2

What You'll Learn

By the end of Phase 3, you'll be able to:

✅ Set up Node.js + TypeScript projects with proper tooling
✅ Build REST APIs with Express.js and type-safe routing
✅ Integrate PostgreSQL/MySQL with Prisma ORM
✅ Implement JWT authentication and authorization
✅ Handle validation with Zod schemas
✅ Structure projects with layers (Controller/Service/Repository)
✅ Build GraphQL APIs with Apollo Server
✅ Write tests with Jest and Supertest
✅ Deploy to production with Docker and PM2
✅ Monitor APIs with logging and health checks

Setting Up a Node.js + TypeScript Project

1. Initialize the Project

mkdir typescript-api
cd typescript-api
npm init -y

2. Install TypeScript and Dependencies

# TypeScript core
npm install --save-dev typescript @types/node tsx nodemon
 
# Development tools
npm install --save-dev @types/express @types/cors @types/dotenv
npm install --save-dev eslint prettier eslint-config-prettier
npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin
 
# Runtime dependencies
npm install express cors dotenv

Package overview:

  • typescript - TypeScript compiler
  • @types/node, @types/express - Type definitions for Node.js and Express
  • tsx - Fast TypeScript execution (alternative to ts-node)
  • nodemon - Auto-restart on file changes
  • express - Web framework for APIs
  • cors - Cross-Origin Resource Sharing middleware
  • dotenv - Environment variable management

3. Configure TypeScript (tsconfig.json)

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Key options:

  • module: "NodeNext" - Latest Node.js ESM/CJS support
  • strict: true - Enable all strict type checking
  • paths - Path aliases (@/ resolves to src/)
  • sourceMap: true - Debugging support

4. Configure ESLint (.eslintrc.json)

{
  "parser": "@typescript-eslint/parser",
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ],
  "parserOptions": {
    "ecmaVersion": 2023,
    "sourceType": "module"
  },
  "rules": {
    "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
    "@typescript-eslint/no-explicit-any": "warn"
  }
}

5. Add Scripts to package.json

{
  "scripts": {
    "dev": "nodemon --watch src --exec tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "lint": "eslint src/**/*.ts",
    "format": "prettier --write src/**/*.ts",
    "test": "jest"
  },
  "type": "module"
}

Script usage:

  • npm run dev - Development with auto-reload
  • npm run build - Compile TypeScript to JavaScript
  • npm start - Run production build
  • npm run lint - Check code quality

6. Project Structure

src/
├── index.ts              # Entry point
├── config/               # Configuration files
│   └── database.ts
├── routes/               # API routes
│   ├── index.ts
│   ├── auth.routes.ts
│   └── user.routes.ts
├── controllers/          # Request handlers
│   ├── auth.controller.ts
│   └── user.controller.ts
├── services/             # Business logic
│   ├── auth.service.ts
│   └── user.service.ts
├── repositories/         # Database access
│   └── user.repository.ts
├── middleware/           # Express middleware
│   ├── auth.middleware.ts
│   ├── error.middleware.ts
│   └── validation.middleware.ts
├── types/                # Type definitions
│   ├── express.d.ts
│   └── models.ts
├── utils/                # Utility functions
│   ├── logger.ts
│   └── jwt.ts
└── prisma/               # Prisma ORM
    └── schema.prisma

Building a REST API with Express.js

1. Create the Entry Point (src/index.ts)

import express, { Application, Request, Response } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import routes from './routes/index.js';
import { errorHandler } from './middleware/error.middleware.js';
 
dotenv.config();
 
const app: Application = express();
const PORT = process.env.PORT || 3000;
 
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
 
// Health check
app.get('/health', (_req: Request, res: Response) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
 
// API routes
app.use('/api', routes);
 
// Error handling (must be last)
app.use(errorHandler);
 
app.listen(PORT, () => {
  console.log(`🚀 Server running on http://localhost:${PORT}`);
});

2. Type-Safe Routing

Create route definitions (src/routes/user.routes.ts):

import { Router } from 'express';
import { UserController } from '../controllers/user.controller.js';
import { authenticate } from '../middleware/auth.middleware.js';
import { validateRequest } from '../middleware/validation.middleware.js';
import { createUserSchema, updateUserSchema } from '../schemas/user.schema.js';
 
const router = Router();
const userController = new UserController();
 
// Public routes
router.get('/users', userController.getAllUsers);
router.get('/users/:id', userController.getUserById);
 
// Protected routes
router.post(
  '/users',
  authenticate,
  validateRequest(createUserSchema),
  userController.createUser
);
 
router.put(
  '/users/:id',
  authenticate,
  validateRequest(updateUserSchema),
  userController.updateUser
);
 
router.delete('/users/:id', authenticate, userController.deleteUser);
 
export default router;

Combine routes (src/routes/index.ts):

import { Router } from 'express';
import authRoutes from './auth.routes.js';
import userRoutes from './user.routes.js';
 
const router = Router();
 
router.use('/auth', authRoutes);
router.use('/', userRoutes);
 
export default router;

3. Controllers with Type Safety

// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/user.service.js';
import { CreateUserDto, UpdateUserDto } from '../types/dto.js';
 
export class UserController {
  private userService: UserService;
 
  constructor() {
    this.userService = new UserService();
  }
 
  getAllUsers = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const page = parseInt(req.query.page as string) || 1;
      const limit = parseInt(req.query.limit as string) || 10;
 
      const result = await this.userService.getAllUsers(page, limit);
 
      res.json({
        success: true,
        data: result.users,
        pagination: {
          page,
          limit,
          total: result.total,
          totalPages: Math.ceil(result.total / limit),
        },
      });
    } catch (error) {
      next(error);
    }
  };
 
  getUserById = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const userId = parseInt(req.params.id);
 
      if (isNaN(userId)) {
        return res.status(400).json({
          success: false,
          error: 'Invalid user ID',
        });
      }
 
      const user = await this.userService.getUserById(userId);
 
      if (!user) {
        return res.status(404).json({
          success: false,
          error: 'User not found',
        });
      }
 
      res.json({ success: true, data: user });
    } catch (error) {
      next(error);
    }
  };
 
  createUser = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const createUserDto: CreateUserDto = req.body;
      const user = await this.userService.createUser(createUserDto);
 
      res.status(201).json({ success: true, data: user });
    } catch (error) {
      next(error);
    }
  };
 
  updateUser = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const userId = parseInt(req.params.id);
      const updateUserDto: UpdateUserDto = req.body;
 
      if (isNaN(userId)) {
        return res.status(400).json({
          success: false,
          error: 'Invalid user ID',
        });
      }
 
      const user = await this.userService.updateUser(userId, updateUserDto);
 
      if (!user) {
        return res.status(404).json({
          success: false,
          error: 'User not found',
        });
      }
 
      res.json({ success: true, data: user });
    } catch (error) {
      next(error);
    }
  };
 
  deleteUser = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const userId = parseInt(req.params.id);
 
      if (isNaN(userId)) {
        return res.status(400).json({
          success: false,
          error: 'Invalid user ID',
        });
      }
 
      await this.userService.deleteUser(userId);
 
      res.status(204).send();
    } catch (error) {
      next(error);
    }
  };
}

Key patterns:

  • Class-based controllers for better organization
  • Arrow functions to preserve this context
  • Error handling delegated to middleware via next(error)
  • Consistent response format with success flag
  • Input validation for route parameters

4. Services Layer (Business Logic)

// src/services/user.service.ts
import { UserRepository } from '../repositories/user.repository.js';
import { CreateUserDto, UpdateUserDto } from '../types/dto.js';
import { User } from '../types/models.js';
import bcrypt from 'bcrypt';
 
export class UserService {
  private userRepository: UserRepository;
 
  constructor() {
    this.userRepository = new UserRepository();
  }
 
  async getAllUsers(page: number, limit: number): Promise<{ users: User[]; total: number }> {
    const offset = (page - 1) * limit;
    const [users, total] = await Promise.all([
      this.userRepository.findAll(limit, offset),
      this.userRepository.count(),
    ]);
 
    // Remove password from response
    const sanitizedUsers = users.map(({ password, ...user }) => user);
 
    return { users: sanitizedUsers as User[], total };
  }
 
  async getUserById(id: number): Promise<User | null> {
    const user = await this.userRepository.findById(id);
 
    if (!user) return null;
 
    // Remove password from response
    const { password, ...sanitizedUser } = user;
    return sanitizedUser as User;
  }
 
  async createUser(createUserDto: CreateUserDto): Promise<User> {
    // Check if email already exists
    const existingUser = await this.userRepository.findByEmail(createUserDto.email);
 
    if (existingUser) {
      throw new Error('Email already in use');
    }
 
    // Hash password
    const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
 
    // Create user
    const user = await this.userRepository.create({
      ...createUserDto,
      password: hashedPassword,
    });
 
    // Remove password from response
    const { password, ...sanitizedUser } = user;
    return sanitizedUser as User;
  }
 
  async updateUser(id: number, updateUserDto: UpdateUserDto): Promise<User | null> {
    const user = await this.userRepository.findById(id);
 
    if (!user) return null;
 
    // Hash password if provided
    if (updateUserDto.password) {
      updateUserDto.password = await bcrypt.hash(updateUserDto.password, 10);
    }
 
    const updatedUser = await this.userRepository.update(id, updateUserDto);
 
    // Remove password from response
    const { password, ...sanitizedUser } = updatedUser;
    return sanitizedUser as User;
  }
 
  async deleteUser(id: number): Promise<void> {
    await this.userRepository.delete(id);
  }
}

Service layer responsibilities:

  • Business logic (hashing passwords, checking duplicates)
  • Orchestrating multiple repository calls
  • Data transformation (removing sensitive fields)
  • Throwing business-level errors

5. Repository Layer (Database Access)

// src/repositories/user.repository.ts
import { prisma } from '../config/database.js';
import { CreateUserDto, UpdateUserDto } from '../types/dto.js';
import { User } from '../types/models.js';
 
export class UserRepository {
  async findAll(limit: number, offset: number): Promise<User[]> {
    return prisma.user.findMany({
      take: limit,
      skip: offset,
      orderBy: { createdAt: 'desc' },
    });
  }
 
  async count(): Promise<number> {
    return prisma.user.count();
  }
 
  async findById(id: number): Promise<User | null> {
    return prisma.user.findUnique({
      where: { id },
    });
  }
 
  async findByEmail(email: string): Promise<User | null> {
    return prisma.user.findUnique({
      where: { email },
    });
  }
 
  async create(data: CreateUserDto): Promise<User> {
    return prisma.user.create({
      data,
    });
  }
 
  async update(id: number, data: UpdateUserDto): Promise<User> {
    return prisma.user.update({
      where: { id },
      data,
    });
  }
 
  async delete(id: number): Promise<void> {
    await prisma.user.delete({
      where: { id },
    });
  }
}

Repository layer responsibilities:

  • Direct database access
  • Query construction
  • No business logic
  • Returns raw database models

Request Validation with Zod

1. Install Zod

npm install zod

2. Define Validation Schemas

// src/schemas/user.schema.ts
import { z } from 'zod';
 
export const createUserSchema = z.object({
  body: z.object({
    name: z.string().min(2, 'Name must be at least 2 characters'),
    email: z.string().email('Invalid email format'),
    password: z
      .string()
      .min(8, 'Password must be at least 8 characters')
      .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
      .regex(/[0-9]/, 'Password must contain at least one number'),
    role: z.enum(['USER', 'ADMIN']).optional().default('USER'),
  }),
});
 
export const updateUserSchema = z.object({
  body: z.object({
    name: z.string().min(2).optional(),
    email: z.string().email().optional(),
    password: z.string().min(8).optional(),
    role: z.enum(['USER', 'ADMIN']).optional(),
  }),
});
 
export const loginSchema = z.object({
  body: z.object({
    email: z.string().email('Invalid email format'),
    password: z.string().min(1, 'Password is required'),
  }),
});
 
// Infer types from schemas
export type CreateUserInput = z.infer<typeof createUserSchema>['body'];
export type UpdateUserInput = z.infer<typeof updateUserSchema>['body'];
export type LoginInput = z.infer<typeof loginSchema>['body'];

3. Validation Middleware

// src/middleware/validation.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
 
export const validateRequest =
  (schema: AnyZodObject) => async (req: Request, res: Response, next: NextFunction) => {
    try {
      await schema.parseAsync({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          success: false,
          error: 'Validation failed',
          details: error.errors.map((err) => ({
            path: err.path.join('.'),
            message: err.message,
          })),
        });
      }
      next(error);
    }
  };

Usage in routes:

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

Benefits:

  • Type-safe validation
  • Automatic type inference from schemas
  • Consistent error responses
  • Reusable schemas

Database Integration with Prisma

1. Install Prisma

npm install --save-dev prisma
npm install @prisma/client
npx prisma init

This creates:

  • prisma/schema.prisma - Database schema
  • .env - Environment variables

2. Configure Database Connection

Edit .env:

DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

For PostgreSQL with Docker:

docker run --name postgres \
  -e POSTGRES_USER=user \
  -e POSTGRES_PASSWORD=password \
  -e POSTGRES_DB=mydb \
  -p 5432:5432 \
  -d postgres:16

3. Define Database Schema

Edit prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  password  String
  role      Role     @default(USER)
  isActive  Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  posts     Post[]
 
  @@map("users")
}
 
model Post {
  id          Int      @id @default(autoincrement())
  title       String
  content     String
  published   Boolean  @default(false)
  authorId    Int
  author      User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  @@map("posts")
}
 
enum Role {
  USER
  ADMIN
}

4. Generate Prisma Client and Migrate

# Create migration
npx prisma migrate dev --name init
 
# Generate TypeScript types
npx prisma generate

This generates fully typed Prisma Client in node_modules/@prisma/client.

5. Create Database Connection

// src/config/database.ts
import { PrismaClient } from '@prisma/client';
 
const prismaClientSingleton = () => {
  return new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  });
};
 
declare global {
  var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
}
 
export const prisma = globalThis.prisma ?? prismaClientSingleton();
 
if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma;

Why this pattern?

  • Prevents multiple Prisma instances in development (hot reload)
  • Enables query logging in development
  • Production-optimized

6. Using Prisma in Repositories

import { prisma } from '../config/database.js';
 
// Type-safe queries with autocomplete
const users = await prisma.user.findMany({
  where: {
    isActive: true,
  },
  include: {
    posts: {
      where: { published: true },
    },
  },
  orderBy: {
    createdAt: 'desc',
  },
});
 
// Create with relations
const user = await prisma.user.create({
  data: {
    email: 'john@example.com',
    name: 'John Doe',
    password: 'hashedPassword',
    posts: {
      create: [
        { title: 'First Post', content: 'Hello World!' },
      ],
    },
  },
  include: {
    posts: true,
  },
});
 
// Transactions
const [deletedPosts, deletedUser] = await prisma.$transaction([
  prisma.post.deleteMany({ where: { authorId: userId } }),
  prisma.user.delete({ where: { id: userId } }),
]);

Prisma advantages:

  • Auto-generated types from schema
  • Type-safe queries
  • Relation handling
  • Migration system
  • Database introspection

Authentication with JWT

1. Install Dependencies

npm install jsonwebtoken bcrypt
npm install --save-dev @types/jsonwebtoken @types/bcrypt

2. JWT Utility Functions

// src/utils/jwt.ts
import jwt from 'jsonwebtoken';
 
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
 
export interface JwtPayload {
  userId: number;
  email: string;
  role: string;
}
 
export const generateToken = (payload: JwtPayload): string => {
  return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
};
 
export const verifyToken = (token: string): JwtPayload => {
  return jwt.verify(token, JWT_SECRET) as JwtPayload;
};

3. Authentication Middleware

// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken, JwtPayload } from '../utils/jwt.js';
 
// Extend Express Request type
declare global {
  namespace Express {
    interface Request {
      user?: JwtPayload;
    }
  }
}
 
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
  try {
    const authHeader = req.headers.authorization;
 
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({
        success: false,
        error: 'No token provided',
      });
    }
 
    const token = authHeader.substring(7); // Remove 'Bearer '
    const decoded = verifyToken(token);
 
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({
      success: false,
      error: 'Invalid or expired token',
    });
  }
};
 
export const authorize = (...roles: string[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({
        success: false,
        error: 'Not authenticated',
      });
    }
 
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        error: 'Insufficient permissions',
      });
    }
 
    next();
  };
};

4. Auth Service

// src/services/auth.service.ts
import bcrypt from 'bcrypt';
import { prisma } from '../config/database.js';
import { generateToken } from '../utils/jwt.js';
 
export class AuthService {
  async register(email: string, password: string, name: string) {
    const existingUser = await prisma.user.findUnique({
      where: { email },
    });
 
    if (existingUser) {
      throw new Error('Email already in use');
    }
 
    const hashedPassword = await bcrypt.hash(password, 10);
 
    const user = await prisma.user.create({
      data: {
        email,
        password: hashedPassword,
        name,
      },
    });
 
    const token = generateToken({
      userId: user.id,
      email: user.email,
      role: user.role,
    });
 
    const { password: _, ...userWithoutPassword } = user;
 
    return {
      user: userWithoutPassword,
      token,
    };
  }
 
  async login(email: string, password: string) {
    const user = await prisma.user.findUnique({
      where: { email },
    });
 
    if (!user) {
      throw new Error('Invalid credentials');
    }
 
    const isPasswordValid = await bcrypt.compare(password, user.password);
 
    if (!isPasswordValid) {
      throw new Error('Invalid credentials');
    }
 
    if (!user.isActive) {
      throw new Error('Account is disabled');
    }
 
    const token = generateToken({
      userId: user.id,
      email: user.email,
      role: user.role,
    });
 
    const { password: _, ...userWithoutPassword } = user;
 
    return {
      user: userWithoutPassword,
      token,
    };
  }
}

5. Auth Routes

// src/routes/auth.routes.ts
import { Router } from 'express';
import { AuthController } from '../controllers/auth.controller.js';
import { validateRequest } from '../middleware/validation.middleware.js';
import { loginSchema, createUserSchema } from '../schemas/user.schema.js';
 
const router = Router();
const authController = new AuthController();
 
router.post('/register', validateRequest(createUserSchema), authController.register);
router.post('/login', validateRequest(loginSchema), authController.login);
 
export default router;

6. Protected Routes Example

import { authenticate, authorize } from '../middleware/auth.middleware.js';
 
// Only authenticated users
router.delete('/users/:id', authenticate, userController.deleteUser);
 
// Only admins
router.get('/admin/users', authenticate, authorize('ADMIN'), adminController.getAllUsers);
 
// Multiple roles
router.post('/posts', authenticate, authorize('ADMIN', 'EDITOR'), postController.createPost);

Error Handling

1. Custom Error Classes

// src/utils/errors.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public isOperational = true
  ) {
    super(message);
    Object.setPrototypeOf(this, AppError.prototype);
  }
}
 
export class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(404, message);
  }
}
 
export class ValidationError extends AppError {
  constructor(message = 'Validation failed') {
    super(400, message);
  }
}
 
export class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(401, message);
  }
}
 
export class ForbiddenError extends AppError {
  constructor(message = 'Forbidden') {
    super(403, message);
  }
}

2. Global Error Handler Middleware

// src/middleware/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/errors.js';
import { Prisma } from '@prisma/client';
import { ZodError } from 'zod';
 
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
  // Operational errors (expected)
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      success: false,
      error: err.message,
    });
  }
 
  // Zod validation errors
  if (err instanceof ZodError) {
    return res.status(400).json({
      success: false,
      error: 'Validation failed',
      details: err.errors,
    });
  }
 
  // Prisma errors
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    if (err.code === 'P2002') {
      return res.status(409).json({
        success: false,
        error: 'Unique constraint violation',
      });
    }
    if (err.code === 'P2025') {
      return res.status(404).json({
        success: false,
        error: 'Record not found',
      });
    }
  }
 
  // Unknown errors (log and return generic message)
  console.error('Unexpected error:', err);
 
  return res.status(500).json({
    success: false,
    error: process.env.NODE_ENV === 'production'
      ? 'Internal server error'
      : err.message,
  });
};

3. Using Custom Errors in Services

import { NotFoundError, ValidationError } from '../utils/errors.js';
 
async getUserById(id: number): Promise<User> {
  const user = await this.userRepository.findById(id);
 
  if (!user) {
    throw new NotFoundError(`User with ID ${id} not found`);
  }
 
  return user;
}
 
async createUser(data: CreateUserDto): Promise<User> {
  const existingUser = await this.userRepository.findByEmail(data.email);
 
  if (existingUser) {
    throw new ValidationError('Email already in use');
  }
 
  return this.userRepository.create(data);
}

Testing with Jest and Supertest

1. Install Testing Dependencies

npm install --save-dev jest @types/jest ts-jest supertest @types/supertest

2. Configure Jest (jest.config.js)

export default {
  preset: 'ts-jest/presets/default-esm',
  testEnvironment: 'node',
  extensionsToTreatAsEsm: ['.ts'],
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1',
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  transform: {
    '^.+\\.tsx?$': [
      'ts-jest',
      {
        useESM: true,
      },
    ],
  },
  coverageDirectory: 'coverage',
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/*.test.ts',
  ],
};

3. Integration Tests Example

// src/__tests__/auth.test.ts
import request from 'supertest';
import app from '../index.js';
import { prisma } from '../config/database.js';
 
describe('Authentication', () => {
  beforeAll(async () => {
    // Connect to test database
    await prisma.$connect();
  });
 
  afterAll(async () => {
    // Clean up
    await prisma.user.deleteMany();
    await prisma.$disconnect();
  });
 
  describe('POST /api/auth/register', () => {
    it('should register a new user', async () => {
      const res = await request(app)
        .post('/api/auth/register')
        .send({
          name: 'John Doe',
          email: 'john@example.com',
          password: 'Password123',
        });
 
      expect(res.status).toBe(201);
      expect(res.body.success).toBe(true);
      expect(res.body.data.user).toHaveProperty('id');
      expect(res.body.data.user.email).toBe('john@example.com');
      expect(res.body.data).toHaveProperty('token');
    });
 
    it('should return 400 for invalid email', async () => {
      const res = await request(app)
        .post('/api/auth/register')
        .send({
          name: 'Jane Doe',
          email: 'invalid-email',
          password: 'Password123',
        });
 
      expect(res.status).toBe(400);
      expect(res.body.success).toBe(false);
    });
  });
 
  describe('POST /api/auth/login', () => {
    beforeEach(async () => {
      await request(app)
        .post('/api/auth/register')
        .send({
          name: 'Test User',
          email: 'test@example.com',
          password: 'Password123',
        });
    });
 
    it('should login with valid credentials', async () => {
      const res = await request(app)
        .post('/api/auth/login')
        .send({
          email: 'test@example.com',
          password: 'Password123',
        });
 
      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(res.body.data).toHaveProperty('token');
    });
 
    it('should return 401 for invalid password', async () => {
      const res = await request(app)
        .post('/api/auth/login')
        .send({
          email: 'test@example.com',
          password: 'WrongPassword',
        });
 
      expect(res.status).toBe(401);
      expect(res.body.success).toBe(false);
    });
  });
});

4. Unit Tests Example

// src/__tests__/user.service.test.ts
import { UserService } from '../services/user.service.js';
import { UserRepository } from '../repositories/user.repository.js';
import { NotFoundError } from '../utils/errors.js';
 
// Mock the repository
jest.mock('../repositories/user.repository.js');
 
describe('UserService', () => {
  let userService: UserService;
  let mockUserRepository: jest.Mocked<UserRepository>;
 
  beforeEach(() => {
    userService = new UserService();
    mockUserRepository = userService['userRepository'] as jest.Mocked<UserRepository>;
  });
 
  describe('getUserById', () => {
    it('should return user when found', async () => {
      const mockUser = {
        id: 1,
        email: 'john@example.com',
        name: 'John Doe',
        password: 'hashedPassword',
        role: 'USER' as const,
        isActive: true,
        createdAt: new Date(),
        updatedAt: new Date(),
      };
 
      mockUserRepository.findById.mockResolvedValue(mockUser);
 
      const result = await userService.getUserById(1);
 
      expect(result).toEqual(expect.not.objectContaining({ password: expect.anything() }));
      expect(result.email).toBe('john@example.com');
    });
 
    it('should throw NotFoundError when user not found', async () => {
      mockUserRepository.findById.mockResolvedValue(null);
 
      await expect(userService.getUserById(999)).rejects.toThrow(NotFoundError);
    });
  });
});

5. Run Tests

# Run all tests
npm test
 
# Run with coverage
npm test -- --coverage
 
# Watch mode
npm test -- --watch

Building a GraphQL API with Apollo Server

1. Install Dependencies

npm install @apollo/server graphql
npm install --save-dev @graphql-tools/schema

2. Define GraphQL Schema

// src/graphql/schema.ts
export const typeDefs = `#graphql
  type User {
    id: ID!
    email: String!
    name: String!
    role: Role!
    isActive: Boolean!
    posts: [Post!]!
    createdAt: String!
  }
 
  type Post {
    id: ID!
    title: String!
    content: String!
    published: Boolean!
    author: User!
    createdAt: String!
  }
 
  enum Role {
    USER
    ADMIN
  }
 
  type AuthPayload {
    user: User!
    token: String!
  }
 
  type Query {
    me: User
    user(id: ID!): User
    users(page: Int, limit: Int): [User!]!
    post(id: ID!): Post
    posts(published: Boolean): [Post!]!
  }
 
  type Mutation {
    register(email: String!, password: String!, name: String!): AuthPayload!
    login(email: String!, password: String!): AuthPayload!
    createPost(title: String!, content: String!): Post!
    publishPost(id: ID!): Post!
    deletePost(id: ID!): Boolean!
  }
`;

3. Implement Resolvers

// src/graphql/resolvers.ts
import { GraphQLError } from 'graphql';
import { prisma } from '../config/database.js';
import { AuthService } from '../services/auth.service.js';
import { verifyToken } from '../utils/jwt.js';
 
interface Context {
  user?: {
    userId: number;
    email: string;
    role: string;
  };
}
 
export const resolvers = {
  Query: {
    me: async (_parent: any, _args: any, context: Context) => {
      if (!context.user) {
        throw new GraphQLError('Not authenticated', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
 
      return prisma.user.findUnique({
        where: { id: context.user.userId },
      });
    },
 
    user: async (_parent: any, args: { id: string }) => {
      return prisma.user.findUnique({
        where: { id: parseInt(args.id) },
      });
    },
 
    users: async (_parent: any, args: { page?: number; limit?: number }) => {
      const page = args.page || 1;
      const limit = args.limit || 10;
      const offset = (page - 1) * limit;
 
      return prisma.user.findMany({
        take: limit,
        skip: offset,
      });
    },
 
    posts: async (_parent: any, args: { published?: boolean }) => {
      return prisma.post.findMany({
        where: args.published !== undefined ? { published: args.published } : {},
      });
    },
  },
 
  Mutation: {
    register: async (_parent: any, args: { email: string; password: string; name: string }) => {
      const authService = new AuthService();
      return authService.register(args.email, args.password, args.name);
    },
 
    login: async (_parent: any, args: { email: string; password: string }) => {
      const authService = new AuthService();
      return authService.login(args.email, args.password);
    },
 
    createPost: async (
      _parent: any,
      args: { title: string; content: string },
      context: Context
    ) => {
      if (!context.user) {
        throw new GraphQLError('Not authenticated', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
 
      return prisma.post.create({
        data: {
          title: args.title,
          content: args.content,
          authorId: context.user.userId,
        },
      });
    },
 
    publishPost: async (_parent: any, args: { id: string }, context: Context) => {
      if (!context.user) {
        throw new GraphQLError('Not authenticated', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
 
      return prisma.post.update({
        where: { id: parseInt(args.id) },
        data: { published: true },
      });
    },
  },
 
  User: {
    posts: async (parent: any) => {
      return prisma.post.findMany({
        where: { authorId: parent.id },
      });
    },
  },
 
  Post: {
    author: async (parent: any) => {
      return prisma.user.findUnique({
        where: { id: parent.authorId },
      });
    },
  },
};

4. Create Apollo Server

// src/graphql/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
import { verifyToken } from '../utils/jwt.js';
 
export const createApolloServer = () => {
  return new ApolloServer({
    typeDefs,
    resolvers,
  });
};
 
export const createGraphQLContext = ({ req }: { req: any }) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
 
  if (!token) {
    return {};
  }
 
  try {
    const user = verifyToken(token);
    return { user };
  } catch {
    return {};
  }
};

5. Integrate with Express

// src/index.ts (update)
import { createApolloServer, createGraphQLContext } from './graphql/server.js';
 
const app = express();
const apolloServer = createApolloServer();
 
await apolloServer.start();
 
// GraphQL endpoint
app.use(
  '/graphql',
  cors(),
  express.json(),
  expressMiddleware(apolloServer, {
    context: createGraphQLContext,
  })
);

6. Query Examples

Register user:

mutation {
  register(
    email: "john@example.com"
    password: "Password123"
    name: "John Doe"
  ) {
    user {
      id
      email
      name
    }
    token
  }
}

Get authenticated user's posts:

query {
  me {
    id
    name
    posts {
      id
      title
      published
    }
  }
}

Deployment

1. Production Build

npm run build

Creates compiled JavaScript in dist/ directory.

2. Environment Variables

Create .env.production:

NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@host:5432/prod_db
JWT_SECRET=your-super-secret-key
JWT_EXPIRES_IN=7d

3. Dockerfile

FROM node:22-alpine AS builder
 
WORKDIR /app
 
COPY package*.json ./
RUN npm ci
 
COPY . .
RUN npm run build
 
FROM node:22-alpine
 
WORKDIR /app
 
COPY package*.json ./
RUN npm ci --only=production
 
COPY --from=builder /app/dist ./dist
COPY prisma ./prisma
 
EXPOSE 3000
 
CMD ["node", "dist/index.js"]

4. Docker Compose (Production)

version: '3.8'
 
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://user:password@postgres:5432/prod_db
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      - postgres
 
  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=prod_db
    volumes:
      - postgres_data:/var/lib/postgresql/data
 
volumes:
  postgres_data:

5. PM2 for Process Management

npm install -g pm2

Create ecosystem.config.js:

module.exports = {
  apps: [{
    name: 'api',
    script: './dist/index.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
  }],
};

Start with PM2:

pm2 start ecosystem.config.js
pm2 save
pm2 startup

Week-by-Week Learning Plan

Week 1: Project Setup & REST API Basics

Days 1-2: Project setup

  • Initialize TypeScript project
  • Configure ESLint, Prettier
  • Set up Express.js with TypeScript
  • Create basic routes

Days 3-4: Layered architecture

  • Implement Controller/Service/Repository pattern
  • Add request validation with Zod
  • Error handling middleware

Days 5-7: Database integration

  • Set up Prisma with PostgreSQL
  • Define schema and migrations
  • Build CRUD operations

Weekend project: Build a simple blog API with posts and comments

Week 2: Authentication & Advanced Features

Days 1-3: Authentication

  • JWT-based authentication
  • User registration and login
  • Protected routes
  • Role-based authorization

Days 4-5: Advanced validation

  • Complex Zod schemas
  • Custom validators
  • File upload handling

Days 6-7: GraphQL API

  • Apollo Server setup
  • Schema definition
  • Resolvers with authentication

Weekend project: Add authentication to blog API and convert one endpoint to GraphQL

Week 3: Testing & Deployment

Days 1-3: Testing

  • Jest configuration
  • Unit tests for services
  • Integration tests with Supertest
  • Mocking Prisma

Days 4-5: Production preparation

  • Environment configuration
  • Logging with Winston
  • Health checks and monitoring
  • Performance optimization

Days 6-7: Deployment

  • Docker containerization
  • PM2 process management
  • Deploy to cloud (Railway, Render, or DigitalOcean)

Weekend project: Deploy your blog API with CI/CD pipeline

Best Practices

1. Project Organization

Use layered architecture (Controller → Service → Repository)
Separate concerns: Routes, business logic, database access
Group by feature, not by file type
Use path aliases (@/ instead of ../../)

2. Type Safety

Enable strict mode in tsconfig.json
Avoid any type - use unknown for truly unknown types
Use Zod for runtime validation + type inference
Define DTOs for request/response types

3. Error Handling

Use custom error classes for operational errors
Centralize error handling in middleware
Never expose stack traces in production
Log errors with context (user ID, request ID)

4. Security

Hash passwords with bcrypt (cost factor 10+)
Use environment variables for secrets
Validate all inputs with Zod
Implement rate limiting for auth endpoints
Use HTTPS in production
Set secure HTTP headers (Helmet.js)

5. Performance

Use connection pooling for database
Index database frequently queried columns
Paginate large result sets
Cache with Redis for expensive queries
Use compression middleware (gzip)

Common Pitfalls

1. Ignoring TypeScript Errors

Don't use @ts-ignore to bypass errors

// Bad
// @ts-ignore
const user = await prisma.user.findUnique();
 
// Good - handle null properly
const user = await prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundError('User not found');

2. Missing Error Handling

Don't forget try-catch in async controllers

// Bad
async createUser(req: Request, res: Response) {
  const user = await userService.createUser(req.body);
  res.json(user);
}
 
// Good
async createUser(req: Request, res: Response, next: NextFunction) {
  try {
    const user = await userService.createUser(req.body);
    res.json(user);
  } catch (error) {
    next(error);
  }
}

3. Not Sanitizing Responses

Never send passwords to clients

// Bad
const user = await prisma.user.findUnique({ where: { id } });
return user; // Includes password!
 
// Good
const user = await prisma.user.findUnique({ where: { id } });
const { password, ...sanitized } = user;
return sanitized;

4. Hardcoding Configuration

Don't hardcode secrets or URLs

// Bad
const JWT_SECRET = 'my-secret-key';
 
// Good
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) throw new Error('JWT_SECRET not configured');

Next Steps

After Phase 3

Congratulations! You now have solid backend development skills with TypeScript. Here's what comes next:

Phase 4: Full-Stack Integration (Coming soon)

  • Connect frontend and backend with tRPC
  • End-to-end type safety
  • Real-time features with WebSockets
  • Deployment strategies

Advanced Topics:

  • Microservices with TypeScript
  • Event-driven architecture
  • gRPC APIs
  • Message queues (Redis, RabbitMQ)
  • Background jobs (BullMQ)

Documentation:

Courses & Tutorials:

Tools:

Summary and Key Takeaways

In Phase 3, you learned how to build production-ready backend applications with TypeScript:

Project setup: Configured TypeScript, ESLint, and modern tooling
REST APIs: Built type-safe Express.js APIs with layered architecture
Database: Integrated Prisma ORM with PostgreSQL
Validation: Used Zod for runtime type checking
Authentication: Implemented JWT-based auth with bcrypt
GraphQL: Created Apollo Server with resolvers
Testing: Wrote unit and integration tests with Jest
Deployment: Containerized with Docker and deployed to production
Best practices: Learned error handling, security, and performance patterns

The full-stack journey:

Start building: The best way to learn is by doing. Pick a project idea (blog, todo app, e-commerce API) and implement it using the patterns you've learned. Share your progress and learn from the community!

Ready to connect your frontend and backend? Check out Phase 4: Full-Stack Integration for end-to-end type safety with tRPC.


Deep Dives

Want to go deeper into the topics covered in this phase?

📘 Deep Dive: Advanced TypeScript Types → — Conditional types, mapped types, branded types, and more 📘 Deep Dive: Node.js API Development → — Production-ready API patterns 📘 Deep Dive: Database & ORMs → — Prisma, Drizzle, and database best practices


Previous: Phase 2: Frontend Development Next: Phase 4: Full-Stack Integration

Happy coding! 🚀

📬 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.