Project Setup
Project Setup and Configuration
Initialize Project
mkdir ts-api-project && cd ts-api-project
npm init -y
npm pkg set type="module"
npm pkg set scripts.dev="tsx watch src/index.ts"
npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node dist/index.js"
npm install --save express zod dotenv jsonwebtoken bcryptjs
npm install --save-dev typescript tsx @types/node @types/express \
@types/jsonwebtoken @types/bcryptjs vitest eslint prettier
Environment Setup
// src/config/env.ts
import 'dotenv/config';
interface Env {
NODE_ENV: 'development' | 'production' | 'test';
PORT: number;
DATABASE_URL: string;
JWT_SECRET: string;
JWT_EXPIRES_IN: string;
}
function getEnv(): Env {
return {
NODE_ENV: (process.env.NODE_ENV as Env['NODE_ENV']) ?? 'development',
PORT: parseInt(process.env.PORT ?? '3000', 10),
DATABASE_URL: process.env.DATABASE_URL!,
JWT_SECRET: process.env.JWT_SECRET!,
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN ?? '24h'
};
}
export const env = getEnv();
Database Setup
// src/config/database.ts
import { env } from './env.js';
interface DatabaseConfig {
host: string;
port: number;
database: string;
ssl: boolean;
}
export const dbConfig: DatabaseConfig = {
host: new URL(env.DATABASE_URL).hostname,
port: parseInt(new URL(env.DATABASE_URL).port || '5432'),
database: new URL(env.DATABASE_URL).pathname.slice(1),
ssl: env.NODE_ENV === 'production'
};
Type Definitions
Type Definitions
Models and DTOs
// src/types/models.ts
export interface User {
id: string;
name: string;
email: string;
passwordHash: string;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
export type UserRole = 'admin' | 'user';
export interface Post {
id: string;
title: string;
content: string;
authorId: string;
published: boolean;
createdAt: Date;
updatedAt: Date;
}
// src/types/dto.ts
export interface CreateUserDTO {
name: string;
email: string;
password: string;
role?: UserRole;
}
export interface LoginDTO {
email: string;
password: string;
}
export interface UpdatePostDTO {
title?: string;
content?: string;
published?: boolean;
}
API Types
// src/types/api.ts
import { User } from './models.js';
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
details?: Record<string, string[]>;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
meta: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
// Type-safe authenticated request
import { Request } from 'express';
export interface AuthenticatedRequest extends Request {
user?: {
id: string;
role: UserRole;
};
}
Repository Layer
Repository Layer
Base Repository
// src/repositories/base-repository.ts
export interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
export interface BaseRepository<T extends BaseEntity> {
findById(id: string): Promise<T | null>;
findAll(options: FindOptions): Promise<T[]>;
create(data: Omit<T, keyof BaseEntity>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T | null>;
delete(id: string): Promise<boolean>;
count(): Promise<number>;
}
export interface FindOptions {
page?: number;
limit?: number;
sort?: string;
order?: 'asc' | 'desc';
}
User Repository
// src/repositories/user-repository.ts
import { User, CreateUserDTO } from '../types/index.js';
import { BaseRepository, FindOptions } from './base-repository.js';
import { db } from '../config/database.js';
export class UserRepository implements BaseRepository<User> {
async findById(id: string): Promise<User | null> {
const result = await db.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return result.rows[0] ?? null;
}
async findAll(options: FindOptions): Promise<User[]> {
const { page = 1, limit = 10, sort = 'createdAt', order = 'desc' } = options;
const offset = (page - 1) * limit;
const result = await db.query(
`SELECT * FROM users ORDER BY ${sort} ${order} LIMIT $1 OFFSET $2`,
[limit, offset]
);
return result.rows;
}
async create(data: Omit<User, keyof BaseEntity>): Promise<User> {
const result = await db.query(
`INSERT INTO users (name, email, password_hash, role)
VALUES ($1, $2, $3, $4) RETURNING *`,
[data.name, data.email, data.passwordHash, data.role]
);
return result.rows[0];
}
async findByEmail(email: string): Promise<User | null> {
const result = await db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0] ?? null;
}
}
Service and Controller Layers
Service and Controller Layers
User Service
// src/services/user-service.ts
import { UserRepository } from '../repositories/user-repository.js';
import { CreateUserDTO, User } from '../types/index.js';
import { hashPassword, comparePassword } from '../utils/password.js';
import { AppError } from '../utils/errors.js';
export class UserService {
constructor(private repo: UserRepository) {}
async create(dto: CreateUserDTO): Promise<Omit<User, 'passwordHash'>> {
const existing = await this.repo.findByEmail(dto.email);
if (existing) {
throw new AppError('EMAIL_EXISTS', 'Email already registered', 409);
}
const passwordHash = await hashPassword(dto.password);
const user = await this.repo.create({
...dto,
passwordHash,
role: dto.role ?? 'user'
} as any);
const { passwordHash: _, ...userWithoutPassword } = user;
return userWithoutPassword;
}
async authenticate(email: string, password: string): Promise<string> {
const user = await this.repo.findByEmail(email);
if (!user) {
throw new AppError('INVALID_CREDENTIALS', 'Invalid email or password', 401);
}
const valid = await comparePassword(password, user.passwordHash);
if (!valid) {
throw new AppError('INVALID_CREDENTIALS', 'Invalid email or password', 401);
}
return generateToken({ id: user.id, role: user.role });
}
}
User Controller
// src/controllers/user-controller.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/user-service.js';
import { AuthenticatedRequest, ApiResponse } from '../types/index.js';
import { CreateUserDTO } from '../types/index.js';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['admin', 'user']).optional()
});
export class UserController {
constructor(private service: UserService) {}
create = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const dto = CreateUserSchema.parse(req.body);
const user = await this.service.create(dto);
res.status(201).json({ success: true, data: user } as ApiResponse<typeof user>);
} catch (error) {
next(error);
}
};
login = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { email, password } = req.body;
const token = await this.service.authenticate(email, password);
res.json({ success: true, data: { token } });
} catch (error) {
next(error);
}
};
getProfile = async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => {
try {
const user = await this.service.findById(req.user!.id);
if (!user) {
throw new AppError('NOT_FOUND', 'User not found', 404);
}
const { passwordHash: _, ...userWithoutPassword } = user;
res.json({ success: true, data: userWithoutPassword });
} catch (error) {
next(error);
}
};
}