TypeScript Patterns
Common TypeScript patterns for backend development - generics, utility types, and type-safe APIs.
8 min read
Type-Safe API Responses#
Standard Response Type#
typescript
// src/types/api.ts
export interface ApiResponse<T> {
data: T;
meta?: {
page?: number;
limit?: number;
total?: number;
};
}
export interface ApiError {
error: {
message: string;
code: string;
details?: Record<string, string[]>;
};
}
// Usage
function sendSuccess<T>(res: Response, data: T, meta?: ApiResponse<T>['meta']): void {
res.json({ data, meta });
}
function sendError(res: Response, status: number, message: string, code: string): void {
res.status(status).json({
error: { message, code }
});
}
Typed Controllers#
typescript
// src/types/controller.ts
import { Request, Response, NextFunction } from 'express';
export type AsyncHandler<
P = {},
ResBody = any,
ReqBody = any,
ReqQuery = {}
> = (
req: Request<P, ResBody, ReqBody, ReqQuery>,
res: Response<ResBody>,
next: NextFunction
) => Promise<void>;
// Usage
export const getUser: AsyncHandler<{ id: string }> = async (req, res, next) => {
const user = await UserService.findById(req.params.id);
res.json({ data: user });
};
Generic Repository Pattern#
Base Repository#
typescript
// src/repositories/baseRepository.ts
import { Model, Document, FilterQuery, UpdateQuery } from 'mongoose';
export class BaseRepository<T extends Document> {
constructor(private model: Model<T>) {}
async findById(id: string): Promise<T | null> {
return this.model.findById(id);
}
async findOne(filter: FilterQuery<T>): Promise<T | null> {
return this.model.findOne(filter);
}
async findMany(
filter: FilterQuery<T>,
options?: { skip?: number; limit?: number; sort?: Record<string, 1 | -1> }
): Promise<T[]> {
let query = this.model.find(filter);
if (options?.skip) query = query.skip(options.skip);
if (options?.limit) query = query.limit(options.limit);
if (options?.sort) query = query.sort(options.sort);
return query;
}
async create(data: Partial<T>): Promise<T> {
return this.model.create(data);
}
async updateById(id: string, data: UpdateQuery<T>): Promise<T | null> {
return this.model.findByIdAndUpdate(id, data, { new: true });
}
async deleteById(id: string): Promise<T | null> {
return this.model.findByIdAndDelete(id);
}
async count(filter: FilterQuery<T>): Promise<number> {
return this.model.countDocuments(filter);
}
}
Specific Repository#
typescript
// src/repositories/userRepository.ts
import { User, IUser } from '../models/User.js';
import { BaseRepository } from './baseRepository.js';
class UserRepositoryClass extends BaseRepository<IUser> {
constructor() {
super(User);
}
async findByEmail(email: string): Promise<IUser | null> {
return this.findOne({ email: email.toLowerCase() });
}
async findActiveUsers(): Promise<IUser[]> {
return this.findMany({ isActive: true, deletedAt: null });
}
}
export const UserRepository = new UserRepositoryClass();
Result Type Pattern#
Handle errors without exceptions:
typescript
// src/types/result.ts
export type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
export function ok<T>(data: T): Result<T, never> {
return { success: true, data };
}
export function err<E>(error: E): Result<never, E> {
return { success: false, error };
}
// Usage
async function findUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'DB_ERROR'>> {
try {
const user = await UserRepository.findById(id);
if (!user) return err('NOT_FOUND');
return ok(user);
} catch {
return err('DB_ERROR');
}
}
// In controller
const result = await findUser(req.params.id);
if (!result.success) {
if (result.error === 'NOT_FOUND') {
return res.status(404).json({ error: 'User not found' });
}
return res.status(500).json({ error: 'Database error' });
}
res.json({ data: result.data });
Utility Types for APIs#
Pick and Omit for DTOs#
typescript
// Full user type from database
interface User {
id: string;
email: string;
password: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
// Response DTO - exclude sensitive fields
type UserResponse = Omit<User, 'password'>;
// Create DTO - only required fields
type CreateUserInput = Pick<User, 'email' | 'password' | 'name'>;
// Update DTO - all fields optional
type UpdateUserInput = Partial<Pick<User, 'name' | 'email'>>;
Mapped Types#
typescript
// Make all properties optional and nullable
type Nullable<T> = { [K in keyof T]: T[K] | null };
// Make specific properties required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
// Make specific properties optional
type OptionalFields<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Usage
type UserWithOptionalName = OptionalFields<User, 'name'>;
Branded Types#
Prevent mixing similar primitive types:
typescript
// src/types/branded.ts
declare const brand: unique symbol;
type Brand<T, B> = T & { [brand]: B };
// Create branded types
export type UserId = Brand<string, 'UserId'>;
export type PostId = Brand<string, 'PostId'>;
export type Email = Brand<string, 'Email'>;
// Factory functions
export function createUserId(id: string): UserId {
return id as UserId;
}
export function createEmail(email: string): Email {
if (!email.includes('@')) {
throw new Error('Invalid email');
}
return email.toLowerCase() as Email;
}
// Now these are different types!
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = createUserId('123');
const postId = createPostId('456');
getUser(userId); // OK
getUser(postId); // Error! Type 'PostId' is not assignable to 'UserId'
Discriminated Unions#
Model state with types:
typescript
// Order states
type Order =
| { status: 'pending'; createdAt: Date }
| { status: 'processing'; createdAt: Date; startedAt: Date }
| { status: 'shipped'; createdAt: Date; startedAt: Date; shippedAt: Date; trackingNumber: string }
| { status: 'delivered'; createdAt: Date; startedAt: Date; shippedAt: Date; deliveredAt: Date }
| { status: 'cancelled'; createdAt: Date; cancelledAt: Date; reason: string };
function getOrderInfo(order: Order): string {
switch (order.status) {
case 'pending':
return 'Order is pending';
case 'processing':
return `Processing since ${order.startedAt}`;
case 'shipped':
return `Tracking: ${order.trackingNumber}`; // TypeScript knows trackingNumber exists
case 'delivered':
return `Delivered at ${order.deliveredAt}`;
case 'cancelled':
return `Cancelled: ${order.reason}`;
}
}
Type Guards#
Narrow types at runtime:
typescript
// Custom type guard
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'email' in obj
);
}
// Usage
const data: unknown = JSON.parse(request.body);
if (isUser(data)) {
console.log(data.email); // TypeScript knows it's a User
}
// Array type guard
function isUserArray(arr: unknown): arr is User[] {
return Array.isArray(arr) && arr.every(isUser);
}
// Error type guard
function isApiError(error: unknown): error is { message: string; code: string } {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
'code' in error
);
}
Generic Service Pattern#
typescript
// src/services/baseService.ts
import { BaseRepository } from '../repositories/baseRepository.js';
import { Document } from 'mongoose';
export abstract class BaseService<T extends Document, CreateDTO, UpdateDTO> {
constructor(protected repository: BaseRepository<T>) {}
async findById(id: string): Promise<T | null> {
return this.repository.findById(id);
}
async findAll(options?: { page?: number; limit?: number }): Promise<{
items: T[];
total: number;
page: number;
limit: number;
}> {
const page = options?.page ?? 1;
const limit = options?.limit ?? 20;
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
this.repository.findMany({}, { skip, limit }),
this.repository.count({}),
]);
return { items, total, page, limit };
}
abstract create(data: CreateDTO): Promise<T>;
abstract update(id: string, data: UpdateDTO): Promise<T | null>;
async delete(id: string): Promise<T | null> {
return this.repository.deleteById(id);
}
}
Implementing the Service#
typescript
// src/services/userService.ts
import { BaseService } from './baseService.js';
import { UserRepository } from '../repositories/userRepository.js';
import { IUser } from '../models/User.js';
import { hashPassword } from '../utils/crypto.js';
interface CreateUserDTO {
email: string;
password: string;
name: string;
}
interface UpdateUserDTO {
name?: string;
email?: string;
}
class UserServiceClass extends BaseService<IUser, CreateUserDTO, UpdateUserDTO> {
constructor() {
super(UserRepository);
}
async create(data: CreateUserDTO): Promise<IUser> {
const hashedPassword = await hashPassword(data.password);
return this.repository.create({
...data,
password: hashedPassword,
});
}
async update(id: string, data: UpdateUserDTO): Promise<IUser | null> {
return this.repository.updateById(id, data);
}
async findByEmail(email: string): Promise<IUser | null> {
return (this.repository as typeof UserRepository).findByEmail(email);
}
}
export const UserService = new UserServiceClass();
Zod Integration#
Infer types from schemas:
typescript
import { z } from 'zod';
// Define schema
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
role: z.enum(['user', 'admin']).default('user'),
});
// Infer type from schema
type CreateUserInput = z.infer<typeof createUserSchema>;
// Equivalent to:
// { email: string; password: string; name: string; role: 'user' | 'admin' }
// Type-safe validation
function validateCreateUser(data: unknown): CreateUserInput {
return createUserSchema.parse(data);
}
// In controller
router.post('/', async (req, res) => {
const validatedData = validateCreateUser(req.body); // Throws if invalid
const user = await UserService.create(validatedData);
res.json({ data: user });
});
Middleware Typing#
Authentication Middleware#
typescript
// src/types/express.d.ts
import { IUser } from '../models/User';
declare global {
namespace Express {
interface Request {
user?: IUser;
}
}
}
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config/index.js';
import { UserService } from '../services/userService.js';
interface JwtPayload {
userId: string;
}
export async function authenticate(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'No token provided' });
return;
}
try {
const payload = jwt.verify(token, config.JWT_SECRET) as JwtPayload;
const user = await UserService.findById(payload.userId);
if (!user) {
res.status(401).json({ error: 'User not found' });
return;
}
req.user = user;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
Key Takeaways#
- Use generics - Create reusable, type-safe patterns
- Branded types - Prevent mixing similar primitives
- Discriminated unions - Model state with types
- Result type - Handle errors without exceptions
- Zod integration - Single source of truth for validation and types
- Extend Express types - Properly type custom request properties
Type Safety Payoff
TypeScript requires more upfront effort, but catches bugs before they reach production. The patterns in this guide eliminate entire categories of runtime errors.
Continue Learning
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.