REST API на Node.js + TypeScript: полный production-гайд 2026
Node.js + TypeScript - стандартный стек для backend в 2026 году. Эта статья - не "hello world". Здесь полный production-код: правильная архитектура, безопасность, валидация, аутентификация и Docker-деплой.
Инициализация проекта
mkdir api && cd api
npm init -y
npm install express @types/express
npm install -D typescript ts-node nodemon @types/node
npx tsc --init
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
npm install pg zod bcryptjs jsonwebtoken dotenv
npm install -D @types/pg @types/bcryptjs @types/jsonwebtoken
npm install express-rate-limit helmet cors
npm install -D @types/cors
Типизированные переменные окружения
Самая частая ошибка - доступ к process.env напрямую без валидации. Это ломается в runtime, а не во время компиляции.
// src/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).default('3000'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('7d'),
BCRYPT_ROUNDS: z.string().transform(Number).default('12'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Ошибки в переменных окружения:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
Подключение к PostgreSQL
// src/config/db.ts
import { Pool, PoolClient } from 'pg';
import { env } from './env';
export const pool = new Pool({
connectionString: env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
pool.on('error', (err) => {
console.error('Неожиданная ошибка PostgreSQL:', err);
process.exit(-1);
});
// Хелпер для транзакций
export async function withTransaction<T>(
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
Кастомные классы ошибок
// src/errors/AppError.ts
export class AppError extends Error {
constructor(
public readonly message: string,
public readonly statusCode: number,
public readonly code?: string
) {
super(message);
this.name = 'AppError';
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} не найден`, 404, 'NOT_FOUND');
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Не авторизован') {
super(message, 401, 'UNAUTHORIZED');
}
}
export class ValidationError extends AppError {
constructor(
message: string,
public readonly details?: unknown
) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT');
}
}
Middleware: валидация через Zod
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
type ValidationTarget = 'body' | 'query' | 'params';
export function validate(schema: ZodSchema, target: ValidationTarget = 'body') {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req[target]);
if (!result.success) {
return res.status(400).json({
success: false,
error: 'Ошибка валидации',
details: result.error.flatten().fieldErrors,
});
}
// Заменяем данные запроса на валидированные/типизированные
req[target] = result.data;
next();
};
}
Аутентификация: JWT + bcrypt
// src/services/auth.service.ts
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { pool } from '../config/db';
import { env } from '../config/env';
import { UnauthorizedError, NotFoundError, ConflictError } from '../errors/AppError';
interface User {
id: string;
email: string;
name: string;
role: string;
created_at: Date;
}
interface TokenPayload {
userId: string;
email: string;
role: string;
}
export async function register(email: string, password: string, name: string): Promise<User> {
// Проверяем, не занят ли email
const existing = await pool.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (existing.rows.length > 0) {
throw new ConflictError('Пользователь с таким email уже существует');
}
const passwordHash = await bcrypt.hash(password, env.BCRYPT_ROUNDS);
const { rows } = await pool.query<User>(
`INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, role, created_at`,
[email.toLowerCase(), passwordHash, name]
);
return rows[0];
}
export async function login(email: string, password: string) {
const { rows } = await pool.query<User & { password_hash: string }>(
'SELECT id, email, name, role, password_hash, created_at FROM users WHERE email = $1',
[email.toLowerCase()]
);
const user = rows[0];
// Защита от timing-атак: всегда вызываем bcrypt.compare, даже если пользователь не найден
const dummyHash = '$2b$12$invalidhashfortimingprotection00000000000000000000000';
const passwordMatch = await bcrypt.compare(password, user?.password_hash ?? dummyHash);
if (!user || !passwordMatch) {
throw new UnauthorizedError('Неверный email или пароль');
}
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role } as TokenPayload,
env.JWT_SECRET,
{ expiresIn: env.JWT_EXPIRES_IN } as jwt.SignOptions
);
const { password_hash: _, ...userWithoutPassword } = user;
return { user: userWithoutPassword, accessToken };
}
export function verifyToken(token: string): TokenPayload {
try {
return jwt.verify(token, env.JWT_SECRET) as TokenPayload;
} catch {
throw new UnauthorizedError('Токен недействителен или истёк');
}
}
Middleware для проверки токена:
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../services/auth.service';
import { UnauthorizedError } from '../errors/AppError';
export interface AuthenticatedRequest extends Request {
user: {
userId: string;
email: string;
role: string;
};
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next(new UnauthorizedError('Токен не предоставлен'));
}
const token = authHeader.slice(7);
const payload = verifyToken(token);
(req as AuthenticatedRequest).user = payload;
next();
}
// Middleware для проверки роли
export function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const { user } = req as AuthenticatedRequest;
if (!roles.includes(user.role)) {
return res.status(403).json({
success: false,
error: 'Недостаточно прав',
});
}
next();
};
}
Паттерн Repository
// src/repositories/user.repository.ts
import { PoolClient } from 'pg';
import { pool } from '../config/db';
import { NotFoundError } from '../errors/AppError';
export interface CreateUserDto {
email: string;
passwordHash: string;
name: string;
}
export interface UpdateUserDto {
name?: string;
email?: string;
}
export interface User {
id: string;
email: string;
name: string;
role: string;
created_at: Date;
updated_at: Date;
}
export class UserRepository {
private client: typeof pool | PoolClient;
constructor(client?: PoolClient) {
this.client = client ?? pool;
}
async findById(id: string): Promise<User> {
const { rows } = await this.client.query<User>(
'SELECT id, email, name, role, created_at, updated_at FROM users WHERE id = $1',
[id]
);
if (!rows[0]) throw new NotFoundError('Пользователь');
return rows[0];
}
async findByEmail(email: string): Promise<User | null> {
const { rows } = await this.client.query<User>(
'SELECT id, email, name, role, created_at, updated_at FROM users WHERE email = $1',
[email.toLowerCase()]
);
return rows[0] ?? null;
}
async update(id: string, data: UpdateUserDto): Promise<User> {
const setClauses: string[] = [];
const values: unknown[] = [];
let paramIdx = 1;
if (data.name !== undefined) {
setClauses.push(`name = $${paramIdx++}`);
values.push(data.name);
}
if (data.email !== undefined) {
setClauses.push(`email = $${paramIdx++}`);
values.push(data.email.toLowerCase());
}
if (setClauses.length === 0) {
return this.findById(id);
}
setClauses.push(`updated_at = now()`);
values.push(id);
const { rows } = await this.client.query<User>(
`UPDATE users SET ${setClauses.join(', ')}
WHERE id = $${paramIdx}
RETURNING id, email, name, role, created_at, updated_at`,
values
);
if (!rows[0]) throw new NotFoundError('Пользователь');
return rows[0];
}
async list(limit = 20, offset = 0): Promise<{ users: User[]; total: number }> {
const [usersResult, countResult] = await Promise.all([
this.client.query<User>(
'SELECT id, email, name, role, created_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[limit, offset]
),
this.client.query<{ count: string }>(
'SELECT COUNT(*)::text FROM users'
),
]);
return {
users: usersResult.rows,
total: parseInt(countResult.rows[0].count, 10),
};
}
}
Роуты и контроллеры
// src/routes/auth.routes.ts
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate';
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
import * as authService from '../services/auth.service';
import { UserRepository } from '../repositories/user.repository';
export const authRouter = Router();
const registerSchema = z.object({
email: z.string().email('Некорректный email'),
password: z.string().min(8, 'Пароль минимум 8 символов'),
name: z.string().min(2, 'Имя минимум 2 символа').max(100),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
authRouter.post('/register', validate(registerSchema), async (req, res, next) => {
try {
const user = await authService.register(
req.body.email,
req.body.password,
req.body.name
);
res.status(201).json({ success: true, data: { user } });
} catch (err) {
next(err);
}
});
authRouter.post('/login', validate(loginSchema), async (req, res, next) => {
try {
const result = await authService.login(req.body.email, req.body.password);
res.json({ success: true, data: result });
} catch (err) {
next(err);
}
});
authRouter.get('/me', authenticate, async (req, res, next) => {
try {
const { userId } = (req as AuthenticatedRequest).user;
const repo = new UserRepository();
const user = await repo.findById(userId);
res.json({ success: true, data: { user } });
} catch (err) {
next(err);
}
});
Rate Limiting через Redis
// src/middleware/rateLimiter.ts
import rateLimit from 'express-rate-limit';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);
// Строгий лимит для аутентификации (защита от brute force)
export const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 10, // максимум 10 попыток
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: 'Слишком много попыток. Подождите 15 минут.',
},
// Ключ по IP + email для более точного ограничения
keyGenerator: (req) => `${req.ip}:${req.body?.email ?? 'unknown'}`,
});
// Общий лимит для API
export const apiRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 минута
max: 100,
standardHeaders: true,
message: {
success: false,
error: 'Превышен лимит запросов',
},
});
Глобальный обработчик ошибок
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import { AppError } from '../errors/AppError';
import { env } from '../config/env';
export function errorHandler(
error: unknown,
req: Request,
res: Response,
_next: NextFunction
) {
// Известные ошибки приложения
if (error instanceof AppError) {
return res.status(error.statusCode).json({
success: false,
error: error.message,
code: error.code,
});
}
// Ошибки PostgreSQL
if (
error instanceof Error &&
'code' in error &&
typeof (error as any).code === 'string'
) {
const pgCode = (error as any).code;
if (pgCode === '23505') {
return res.status(409).json({ success: false, error: 'Запись уже существует' });
}
if (pgCode === '23503') {
return res.status(400).json({ success: false, error: 'Связанная запись не найдена' });
}
}
// Неожиданные ошибки
console.error('Необработанная ошибка:', error);
return res.status(500).json({
success: false,
error: 'Внутренняя ошибка сервера',
// В разработке показываем stack trace
...(env.NODE_ENV === 'development' && error instanceof Error
? { stack: error.stack }
: {}),
});
}
Сборка приложения
// src/app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { authRouter } from './routes/auth.routes';
import { apiRateLimiter } from './middleware/rateLimiter';
import { errorHandler } from './middleware/errorHandler';
const app = express();
// Безопасность
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*',
credentials: true,
}));
// Парсинг тела запроса
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Rate limiting
app.use('/api', apiRateLimiter);
// Healthcheck - без rate limit, используется для Docker/k8s
app.get('/health', (_, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Роуты
app.use('/api/auth', authRouter);
// 404
app.use('*', (_, res) => {
res.status(404).json({ success: false, error: 'Маршрут не найден' });
});
// Обработчик ошибок - должен быть последним
app.use(errorHandler);
export { app };
// src/index.ts
import { app } from './app';
import { pool } from './config/db';
import { env } from './config/env';
async function start() {
// Проверяем подключение к базе данных
await pool.query('SELECT 1');
console.log('✅ Подключение к PostgreSQL установлено');
const server = app.listen(env.PORT, () => {
console.log(`🚀 Сервер запущен на порту ${env.PORT} [${env.NODE_ENV}]`);
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM получен, завершаем работу...');
server.close(async () => {
await pool.end();
console.log('Сервер остановлен');
process.exit(0);
});
});
}
start().catch((err) => {
console.error('Ошибка запуска:', err);
process.exit(1);
});
Docker: мультистадийная сборка
# Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && cp -r node_modules /tmp/prod_modules
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build # tsc → dist/
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nodeapp
COPY --from=builder --chown=nodeapp:nodejs /app/dist ./dist
COPY --from=deps --chown=nodeapp:nodejs /tmp/prod_modules ./node_modules
COPY --chown=nodeapp:nodejs package*.json ./
USER nodeapp
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
# docker-compose.yml (для локальной разработки)
version: '3.9'
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://dev:dev@postgres:5432/devdb
JWT_SECRET: dev-secret-at-least-32-characters-long
NODE_ENV: development
volumes:
- ./src:/app/src # hot reload через nodemon
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: devdb
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev"]
interval: 5s
retries: 5
Структура проекта
src/
├── config/
│ ├── db.ts # подключение к PostgreSQL
│ └── env.ts # типизированные переменные окружения
├── errors/
│ └── AppError.ts # классы ошибок
├── middleware/
│ ├── auth.ts # JWT-аутентификация
│ ├── errorHandler.ts # глобальный обработчик ошибок
│ ├── rateLimiter.ts # rate limiting
│ └── validate.ts # Zod-валидация
├── repositories/
│ └── user.repository.ts
├── routes/
│ └── auth.routes.ts
├── services/
│ └── auth.service.ts
├── app.ts # Express-приложение
└── index.ts # точка входа
Aunimeda разрабатывает production-ready backend API на Node.js и TypeScript для веб и мобильных приложений.
Обсудить проект. Смотрите также: Разработка ПО, Разработка сайтов, DevOps