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 -y2. 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 dotenvPackage overview:
typescript- TypeScript compiler@types/node,@types/express- Type definitions for Node.js and Expresstsx- Fast TypeScript execution (alternative to ts-node)nodemon- Auto-restart on file changesexpress- Web framework for APIscors- Cross-Origin Resource Sharing middlewaredotenv- 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 supportstrict: true- Enable all strict type checkingpaths- Path aliases (@/resolves tosrc/)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-reloadnpm run build- Compile TypeScript to JavaScriptnpm start- Run production buildnpm 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.prismaBuilding 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
thiscontext - Error handling delegated to middleware via
next(error) - Consistent response format with
successflag - 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 zod2. 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 initThis 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:163. 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 generateThis 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/bcrypt2. 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/supertest2. 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 -- --watchBuilding a GraphQL API with Apollo Server
1. Install Dependencies
npm install @apollo/server graphql
npm install --save-dev @graphql-tools/schema2. 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 buildCreates 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=7d3. 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 pm2Create 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 startupWeek-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)
Recommended Resources
Documentation:
Courses & Tutorials:
- Total TypeScript - Advanced type challenges
- Backend Master - Production backend patterns
Tools:
- Postman - API testing
- Prisma Studio - Database GUI
- GraphQL Playground - GraphQL IDE
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:
- ✅ Phase 1: TypeScript Fundamentals
- ✅ Phase 2: Frontend Development
- ✅ Phase 3: Backend Development (this post)
- ⏭️ Phase 4: Full-Stack Integration (coming soon)
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.