Эта статья поможет вам создать приложение Express 5 с поддержкой TypeScript.
Вы настроите готовый к продакшну проект с помощью различных инструментов для линтинга, тестирования и проверки типов. В случае, если вы новичок в REST API, не волнуйтесь, эта статья также включает объяснения основных концепций, которые следует знать, таких как маршрутизация (роутинг) и аутентификация.
Настоятельно рекомендую писать код вместе со мной. Мы будем использовать подход «Разработка через тестирование» (test-driven development, TDD) для создания REST API, который может стать основой вашего следующего приложения Express.
Прим. пер.: в коде оригинальной статьи встречаются устаревшие (deprecated) свойства и методы. Также некоторые оригинальные тесты работают нестабильно. Я позволил себе внести необходимые коррективы. Однако, учитывая объем материала, я вполне мог что-то упустить, поэтому вот ссылка на мой вариант полностью работоспособного кода приложения.
Это первая часть туториала.
❯ Инициализация проекта
Создаем новую директорию для проекта:
mkdir express-ts-app cd express-ts-app
Инициализируем проект с помощью npm:
npm init -y
Создаем файл package.json в директории проекта.
Модифицируем поле "main" и добавляем "type": "module" в этот файл:
{ "main": "dist/index.js", "type": "module" // Другие поля... }
Устанавливаем Express:
npm i express
Устанавливаем TypeScript и необходимые определения типов как зависимости для разработки:
npm i -D typescript tsx @types/node @types/express
Инициализируем TypeScript:
npx tsc --init
Эта команда создает файл tsconfig.json. Обновляем его следующим образом:
{ "compilerOptions": { "allowJs": true, "esModuleInterop": true, "isolatedModules": true, "lib": [ "ESNext" ], "module": "NodeNext", "moduleDetection": "force", "noImplicitOverride": true, "noUncheckedIndexedAccess": true, "outDir": "dist", "paths": { "~/*": [ "./src/*" ] }, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "target": "ES2023", "verbatimModuleSyntax": true }, "exclude": [ "node_modules", "dist" ], "include": [ "src/**/*" ] }
Создаем директорию src для исходных файлов приложения:
mkdir src
Внутри src создаем файл index.ts:
import express from 'express'; const app = express(); const port = Number(process.env.PORT) || 3000; app.get('/', (request, response) => { response.send('Express + TypeScript Server'); }); app.listen(port, () => { console.log(`Server is running at http://localhost:${port}`); });
Добавляем в package.json скрипты для запуска и сборки приложения:
"scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "tsx watch src/index.ts" }
buildкомпилирует TS в JSstartзапускает скомпилированный код JSdevзапускает код TS напрямую с перезагрузкой в реальном времени (hot reload)
Запускаем сервер для разработки:
npm run dev
Переходим по адресу http://localhost:3000 и убеждаемся в корректной работе сервера.
Для быстрой проверки работоспособности сервера можно использовать curl:
curl http://localhost:3000 Express + TypeScript Server
❯ ESLint и Prettier
Устанавливаем ESLint и Prettier для обеспечения согласованного стиля кода и раннего перехвата потенциальных ошибок:
npm i -D eslint typescript-eslint eslint-config-prettier eslint-plugin-prettier eslint-plugin-simple-import-sort eslint-plugin-unicorn prettier @vitest/eslint-plugin
Создаем файл prettier.config.js. Мне нравятся следующие настройки, но вы можете изменить их на свой вкус:
export default { arrowParens: 'avoid', bracketSameLine: false, bracketSpacing: true, htmlWhitespaceSensitivity: 'css', insertPragma: false, jsxSingleQuote: false, plugins: [], printWidth: 80, proseWrap: 'always', quoteProps: 'as-needed', requirePragma: false, semi: true, singleQuote: true, tabWidth: 2, trailingComma: 'all', useTabs: false, };
Прим. пер.: я добавил в этот файл endOfLine: 'auto' для правильной обработки символов переноса строки в Windows.
Создаем файл eslint.config.js:
import eslint from '@eslint/js'; import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import simpleImportSort from 'eslint-plugin-simple-import-sort'; import vitest from '@vitest/eslint-plugin'; export default defineConfig([ eslint.configs.recommended, ...tseslint.configs.recommended, eslintPluginUnicorn.configs['recommended'], { files: ['**/*.{js,ts}'], ignores: ['**/*.js', 'dist/**/*', 'node_modules/**/*'], plugins: { 'simple-import-sort': simpleImportSort, }, rules: { 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', 'unicorn/better-regex': 'warn', 'unicorn/no-process-exit': 'off', 'unicorn/no-array-reduce': 'off', 'unicorn/prevent-abbreviations': 'off', }, }, { files: ['src/**/*.test.{js,ts}'], ...vitest.configs.recommended, }, eslintPluginPrettierRecommended, ]);
Эта настройка комбинирует несколько наборов правил ESLint.
Сначала расширяются рекомендуемые правила JS и TS, затем добавляются предложения Unicorn по улучшению кода (некоторые из них кастомизируются).
Она также включает плагин simple-import-sort для автоматической сортировки инструкций импорта и экспорта.
Для тестов применяются рекомендуемые правила Vitest для обеспечения их следования лучшим практикам.
Наконец, добавляется плагин Prettier для интеграции форматирования кода в процесс линтинга, поэтому код остается одновременно синтаксически верным и стилистически согласованным.
Добавляем в package.json скрипты для линтинга и форматирования:
"scripts": { "format": "prettier --write .", "lint": "eslint .", "lint:fix": "eslint . --fix", }
❯ Vitest и Supertest
Устанавливаем зависимости для тестирования:
npm i -D vitest vite-tsconfig-paths supertest @types/supertest @faker-js/faker
Создаем файл vitest.config.ts:
import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [tsconfigPaths()], test: { environment: 'node' }, });
Добавляем скрипт для запуска тестов в package.json:
"scripts": { "test": "vitest" }
❯ Разделение на сервер и приложение
Файл src/index.ts сейчас выполняет 2 функции. Он является и приложением, и сервером.
В контексте создания REST API с помощью Express «app» указывает на приложение Express. Приложение содержит посредников (промежуточное ПО, middleware) и роуты, а также обрабатывает запросы HTTP. Другими словами, приложение - это логика, выполняемая на сервере.
«server» - это сервер HTTP. Он регистрирует сетевые соединения и создается при вызове app.listen().
Прим. пер.: автор предлагает удалить index.ts и создать 2 новых файла. Я предлагаю сделать немного проще.
Создаем файл src/app.ts:
import express from 'express'; export function buildApp() { const app = express(); // Посредник для разбора (парсинга) JSON app.use(express.json()); return app; }
Посредник express.json() нужен для обработки данных в формате JSON из входящих запросов.
Модифицируем файл src/index.ts:
import { buildApp } from './app.js'; const port = Number(process.env.PORT) || 3000; const app = buildApp(); // Запускаем сервер и перехватываем возвращаемый экземпляр сервера const server = app.listen(port, () => { console.log(`Server is running at http://localhost:${port}`); }); // Обрабатываем сигнал SIGTERM для мягкой (gracefully) остановки сервера process.on('SIGTERM', () => { console.log('SIGTERM signal received: closing HTTP server'); server.close(() => { console.log('HTTP server closed'); }); });
Обратите внимание на расширение .js при импорте файла app.ts. При указании "module": "NodeNext" в tsconfig.json TS следует правилу разрешения модулей Node.js, которое требует явного указания расширений в импортах. Несмотря на то, что мы пишем код на TS, он компилируется в JS, поэтому нужно импортировать файлы .js (например, import { buildApp } from './app.js'). Это гарантирует, что Node.js обнаружит правильные файлы во время выполнения, и предотвращает ошибки.
❯ Логгирование
При создании серверов необходимо мониторить поведение системы путем отслеживания запросов, что помогает в поиске и устранении проблем. Популярным решением является использование такого посредника, как morgan.
Устанавливаем зависимости:
npm i morgan && npm i -D @types/morgan
Добавляем morgan в приложение:
// src/index.ts import morgan from 'morgan'; import { buildApp } from './app.js'; const port = Number(process.env.PORT) || 3000; const app = buildApp(); // Настраиваем логгирование с помощью `morgan` на основе среды выполнения кода const environment = process.env.NODE_ENV || 'development'; app.use(environment === 'development' ? morgan('dev') : morgan('tiny')); const server = app.listen(port, () => { console.log(`Server is running at http://localhost:${port}`); }); process.on('SIGTERM', () => { console.log('SIGTERM signal received: closing HTTP server'); server.close(() => { console.log('HTTP server closed'); }); });
Формат логов morgan настраивается в зависимости от среды выполнения кода. Формат dev предоставляет цветные логи для локальной разработки, а tiny - минимальные логи для продакшна.
morgan лучше настраивать в коде сервера, поскольку в тестах будет использоваться buildApp(). Настройка morgan в коде приложения будет загромождать вывод тестов лишними логами.
❯ Группировка по функционалу
Перед реализацией первой фичи (feature), обсудим общую структуру приложения Express.
В этом туториале файлы будут группироваться по функционалу (фичам). Вот типичная структура такого приложения Express:
. ├── eslint.config.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── src │ ├── app.ts │ ├── features │ │ ├── другие фичи... │ │ └── feature │ │ ├── ... │ │ ├── feature-model.ts │ │ ├── feature-controller.ts │ │ ├── feature-routes.ts │ │ └── feature.test.ts │ ├── другие директории... │ ├── routes.ts │ └── server.ts ├── tsconfig.json └── vitest.config.ts
При разработке приложения Express, как правило, следуют шаблону MVC:
model (модель) - это код, взаимодействующий с базой данных или внешними API
view (представление) - код, отвечающий за отображение данных и пользовательский интерфейс
controller (контроллер) - логика, выполняемая при доступе к роуту. Он соединяет модель и представление, обновляет модель и определяет представление для отображения
Если ваше приложение - это чистый бэкенд REST API, как в этом туториале, вам не нужен слой представления.
❯ Роуты, конечные точки и контроллеры
В дизайне API роут (route) определяет путь (path) и метод HTTP (например, GET, POST), которые используются клиентом для доступа к определенному ресурсу или функционалу. Конечная точка (endpoint) - это определенный URL, по которому доступен этот ресурс или функционал. Контроллер содержит логику, выполняемую при доступе к роуту. Таким образом, роуты и конечные точки определяют, как и где клиенты могут получить ресурсы, а контроллеры - что происходит при доступе к этим роутам.
Роуты и конечные точки часто используются как синонимы, но технически:
роут представляет собой комбинацию метода HTTP и пути URL
конечная точка представляет собой конкретный URL (который может содержать метод при выполнении полной операции API)
контроллер - это контейнер для связанных методов/операций/обработчиков. Обычно, в нем определяется логика обработки запросов к роутам/конечным точкам
метод/операция (action) - функция контроллера для обработки определенных запросов
Рассмотрим такой запрос HTTP:
GET https://api.example.com/users/123
конечная точка - https://api.example.com/users/123
роут - GET /users/:id
операция контроллера - функция
getUserById(операция/метод/обработчик) в объектеuserControllerи/или в файлеuser-controller.ts
В случае длинных роутов, таких как /api/v1/organizations/:slug/members/:id, конечная точки может выглядеть так:
GET https://api.example.com/api/v1/organizations/acme/members/123
Каждая часть роута имеет свое название:
/api - основной путь (base path) или пространство имен (namespace) API
/v1 - сегмент версии API
/organizations - путь первичного (primary) ресурса
/:slug - параметр роута для идентификатора организации
/members - путь вложенного (nested) ресурса - /:id - параметр роута для идентификатора участника
❯ Конечная точка проверки здоровья
Приложение настроено, и мы готовы писать первый тест для первой фичи.
Начнем с создания простой конечной точки проверки здоровья (health check). Такие конечные точки позволяют системам мониторинга, таким как балансировщики нагрузки или оркестраторы (например, Kubernetes), определять, что приложение работает правильно и готово к обработке траффика. Эти системы помогают обнаружить проблемы, такие как упавшие процессы, сервисы или зависимости. Оркестраторы позволяют восстанавливать состояние приложение и мягко откатывать новые версии.
Создаем тест для конечной точки проверки здоровья:
// src/features/health-check/health-check.test.ts import request from 'supertest'; import { describe, expect, test } from 'vitest'; import { buildApp } from '~/app.js'; describe('/api/v1/health-check', () => { test('дано: запрос GET, ожидается: возврат статуса 200 с сообщением, отметкой времени и временем работы', async () => { const app = buildApp(); const actual = await request(app).get('/api/v1/health-check').expect(200); const expected = { message: 'OK', timestamp: expect.any(Number), uptime: expect.any(Number), }; expect(actual.body).toEqual(expected); }); });
Добавляем контроллер с одним обработчиком для конечной точки проверки здоровья:
// src/features/health-check/health-check-controller.ts import type { NextFunction, Request, Response } from 'express'; export async function healthCheckHandler( request: Request, response: Response, next: NextFunction, ) { try { const body = { message: 'OK', timestamp: Date.now(), uptime: process.uptime(), }; response.json(body); } catch (error) { next(error); } }
Здесь мы просто создаем объект, содержащий сообщение, отметку времени и время работы, и отправляем его в формате JSON с дефолтным статусом 200.
Мы используем блок try-catch для обработки ошибок и вызываем функцию next для передачи ошибки соответствующему посреднику. Мы не создавали таких посредников, поэтому Express будет использовать своего встроенного посредника для обработки ошибок. Этот обработчик выводит ошибку в консоль и отправляет простой ответ с ошибкой клиенту, например, статус-код HTTP 500 и сообщение Internal Server Error.
Каждая фича должна иметь хотя бы одного контроллера и один роутер. Создаем роутер:
// src/features/health-check/health-check-routes.ts import { Router } from 'express'; import { healthCheckHandler } from './health-check-controller.js'; const router = Router(); router.get('/', healthCheckHandler); export { router as healthCheckRoutes };
Создаем основной файл для всех роутов:
// src/routes.ts import { Router } from 'express'; import { healthCheckRoutes } from '~/features/health-check/health-check-routes.js'; export const apiV1Router = Router(); apiV1Router.use('/health-check', healthCheckRoutes);
Мы определяем основной путь /health-check для роутов проверки здоровья, где /health-check - это путь основного ресурса.
В дальнейшем при миграции API в файле routes.ts можно определить другую версию API (например, apiV2Router).
Добавляем роуты в приложение:
// src/app.ts import type { Express } from 'express'; import express from 'express'; import { apiV1Router } from './routes.js'; export function buildApp(): Express { const app = express(); app.use(express.json()); // Группируем роуты под /api/v1 app.use('/api/v1', apiV1Router); return app; }
Запускаем тест:
npm run test ✓ src/features/health-check/health-check.test.ts (1 test) 10ms ✓ /api/v1/health-check > given: a GET request, should: return a 200 with a message, timestamp and uptime Test Files 1 passed (1) Tests 1 passed (1) Start at 14:01:14 Duration 99ms PASS Waiting for file changes... press h to show help, press q to quit
❯ asyncHandler
Шаблон использования next() в обработчиках является довольно утомительным. Он заставляет использовать 3 аргумента, добавляет дополнительный слой и делает код менее читаемым и более объемным.
Создадим вспомогательную функцию, оборачивающую обработчик в блок try-catch и вызывающую next() с ошибкой:
// src/utils/async-handler.ts import type { NextFunction, Request, Response } from 'express'; import type { ParamsDictionary } from 'express-serve-static-core'; import type { ParsedQs } from 'qs'; /** * Утилита, оборачивающая асинхронный обработчик роута (без `next()`), чтобы любая ошибка автоматически * передавалась в `next()`. Это позволяет избежать включения блоков `try/catch` в каждый асинхронный обработчик. * * @param fn Асинхронный обработчик запросов Express, возвращающий промис. * @returns Стандартный обработчик запросов Express. */ export function asyncHandler< P = ParamsDictionary, ResponseBody = unknown, RequestBody = unknown, RequestQuery = ParsedQs, LocalsObject extends Record<string, unknown> = Record<string, unknown>, >( function_: ( request: Request<P, ResponseBody, RequestBody, RequestQuery, LocalsObject>, response: Response<ResponseBody, LocalsObject>, ) => Promise<void>, ): ( request: Request<P, ResponseBody, RequestBody, RequestQuery, LocalsObject>, response: Response<ResponseBody, LocalsObject>, next: NextFunction, ) => Promise<void> { return async function ( request: Request<P, ResponseBody, RequestBody, RequestQuery, LocalsObject>, response: Response<ResponseBody, LocalsObject>, next: NextFunction, ): Promise<void> { try { await function_(request, response); } catch (error) { next(error); } }; }
Здесь много строчек кода - все ради того, чтобы TS был счастлив. На самом деле, все сводится к этому:
// temp-async-handler.js function asyncHandler(fn) { return async function (request, response, next) { try { await fn(request, response); } catch (error) { next(error); } }; }
Мы вызываем asyncHandler() с обработчиком, и она возвращает новый обработчик, который можно использовать в роутере.
Это позволяет упростить код обработчика:
// src/features/health-check/health-check-controller.ts import type { Request, Response } from 'express'; export async function healthCheckHandler(request: Request, response: Response) { const body = { message: 'OK', timestamp: Date.now(), uptime: process.uptime(), }; response.json(body); }
Добавляем asyncHandler() в файл health-check-routes.ts:
import { Router } from 'express'; import { asyncHandler } from '~/utils/async-handler.js'; import { healthCheckHandler } from './health-check-controller.js'; const router = Router(); router.get('/', asyncHandler(healthCheckHandler)); export { router as healthCheckRoutes };
❯ База данных
В этом туториале мы будем использовать Prisma с PostgreSQL. Установите сервер Postgres для создания локальной БД.
Прим. пер.: команда для создания Postgres в Docker:
docker run --name db -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=mydb -v postgres_data:/var/lib/postgresql/data -d postgres
Устанавливаем зависимости:
npm i -D prisma && npm i @prisma/client @paralleldrive/cuid2
Инициализируем Prisma:
npx prisma init
Эта команда генерирует файлы .env и prisma/prisma.schema. Убедитесь, что переменная DATABASE_URL в .env содержит правильные данные для подключения к БД.
Прим. пер.: DATABASE_URL для Postgres в Docker:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mydb?schema=public"
Добавляем следующие скрипты в package.json:
"prisma:deploy": "npx prisma migrate deploy && npx prisma generate", "prisma:migrate": "npx prisma migrate dev --name", "prisma:push": "npx prisma db push && npx prisma generate", "prisma:seed": "tsx ./prisma/seed.ts", "prisma:setup": "prisma generate && prisma migrate deploy && prisma db push", "prisma:studio": "npx prisma studio", "prisma:wipe": "npx prisma migrate reset --force && npx prisma db push",
Для этого туториала важен только скрипт prisma:setup. Он создает БД и генерирует клиента Prisma.
Полное объяснение всех скриптов можно найти в моей статье "How To Set Up Next.js 15 For Production In 2025".
Добавляем модель UserProfile в файл prisma/schema.prisma:
model UserProfile { id String @id @default(cuid(2)) email String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt name String @default("") hashedPassword String }
Прим. пер.: рекомендую установить расширение Prisma для VSCode.
Выполняем команду npm run prisma:setup.
Создаем файл database.ts для подключения к БД:
// src/database.ts import { PrismaClient } from '@prisma/client'; declare global { // eslint-disable-next-line no-var var prisma: PrismaClient | undefined; } export const prisma = globalThis.prisma || new PrismaClient(); if (process.env.NODE_ENV !== 'production') { globalThis.prisma = prisma; }
Прим. пер.: это позволяет избежать создания нового экземпляра Prisma при каждой hot reload в режиме для разработки.
❯ Фасады
При работе с любым внешним API, БД или другим сервисом хорошей идеей является создание фасада (facade). Фасад - это обертка сервиса, предоставляющая упрощенный интерфейс для сложной подсистемы.
Фасады полезны по двум причинам:
Рост сопротивления поставщиков - фасады позволяют быстро оборачивать поставщиков (провайдеров). Например, переключение с Postgres на MongoDB путем одного изменения. Мы обновляем реализацию (структуру) фасада, а код, использующий фасад, остается прежним.
Упрощение кода - фасад ограничивает API тем, что нам требуется. Он уменьшает количество кода, который надо писать, поскольку мы передаем лишь нужные аргументы и получаем только нужные нам значения. Код также становится чище за счет описательных названий функций.
Создаем файл для фасада:
// src/features/user-profile/user-profile-model.ts import { prisma } from '~/database.js'; import type { Prisma, UserProfile } from '~/generated/prisma/index.js'; /* CREATE */ /** * Сохраняет профиль пользователя в БД. * * @param userProfile Профиль пользователя для сохранения. * @returns Сохраненный профиль пользователя. */ export async function saveUserProfileToDatabase( userProfile: Prisma.UserProfileCreateInput, ) { return prisma.userProfile.create({ data: userProfile }); } /* READ */ /** * Извлекает профиль пользователя по его id. * * @param id Идентификатор профиля пользователя. * @returns Профиль пользователя или `null`. */ export async function retrieveUserProfileFromDatabaseById( id: UserProfile['id'], ) { return prisma.userProfile.findUnique({ where: { id } }); } /** * Извлекает профиль пользователя по его email. * * @param email email профил�� пользователя. * @returns Профиль пользователя или `null`. */ export async function retrieveUserProfileFromDatabaseByEmail( email: UserProfile['email'], ) { return prisma.userProfile.findUnique({ where: { email } }); } /** * Извлекает несколько профилей пользователей. * * @param page Номер страницы (начиная с 1). * @param pageSize Количество профилей на страницу. * @returns Список профилей пользователей. */ export async function retrieveManyUserProfilesFromDatabase({ page = 1, pageSize = 10, }: { page?: number; pageSize?: number; }) { const skip = (page - 1) * pageSize; return prisma.userProfile.findMany({ skip, take: pageSize, orderBy: { createdAt: 'desc' }, }); } /* UPDATE */ /** * Обновляет профиль пользователя по его id. * * @param id Идентификатор профиля пользователя. * @param data Новые данные профиля. * @returns Обновленный профиль пользователя. */ export async function updateUserProfileInDatabaseById({ id, data, }: { id: UserProfile['id']; data: Prisma.UserProfileUpdateInput; }) { return prisma.userProfile.update({ where: { id }, data }); } /* DELETE */ /** * Удаляет профиль пользователя по его id. * * @param id Идентификатор профиля пользователя. * @returns Удаленный профиль пользователя. */ export async function deleteUserProfileFromDatabaseById(id: UserProfile['id']) { return prisma.userProfile.delete({ where: { id } }); }
Прим. пер.: обратите внимание, что в оригинале типы Prisma и UserProfile импортируются из @prisma/client. В новых версиях Prisma они должны импортироваться из ~/generated/prisma/index.js.
Как правило, мы создаем полный набор операций CRUD (Create, Read, Update, Delete) для любой модели в соответствующем файле.
Для создания профиля пользователя экспортируется функция, принимающая профиль и записывающая его в БД с помощью метода Prisma create. Это демонстрирует шаблон фасада в действии: Prisma, сложная подсистема, предоставляет большой API со множеством возможностей, но фасад упрощает его до простого сохранения одного профиля пользователя.
В разделе чтения имеются функции для извлечения профиля пользователя по уникальному id или email, а также функция для получения нескольких профилей с пагинацией и упорядочением по убыванию (самые последние (по времени создания) профили находятся в начале списка).
Операция обновления обрабатывается функцией, принимающей id и набор новых данных и обновляющей соответствующий профиль пользователя в БД.
Наконец, функция deleteUserProfileFromDatabaseById удаляет профиль с указанным id.
❯ Фабричные функции
Фабричная функция (factory function) - это просто функция, которая возвращает объект. Этот объект обычно представляет осмысленную единицу приложения, такую как запись в БД, кастомная структура данных или объект в ООП. Позже мы будем использовать фабричные функции для создания фиктивных данных для тестов.
Создаем общий тип Factory, который будет использоваться любой фабрикой:
// src/utils/types.ts /** * Произвольная фабричная функция с сигнатурой `Shape`. */ export type Factory<Shape> = (object?: Partial<Shape>) => Shape;
Этот тип позволяет перезаписывать дефолтные значения объекта, обеспечивая наличия всех необходимых свойств.
Создаем фабричную функцию для профиля пользователя:
// src/features/user-profile/user-profile-factories.ts import { faker } from '@faker-js/faker'; import { createId } from '@paralleldrive/cuid2'; import type { UserProfile } from '~/generated/prisma/index.js'; import type { Factory } from '~/utils/types.js'; export const createPopulatedUserProfile: Factory<UserProfile> = ({ id = createId(), email = faker.internet.email(), name = faker.person.fullName(), updatedAt = faker.date.recent({ days: 10 }), createdAt = faker.date.past({ years: 3, refDate: updatedAt }), hashedPassword = faker.string.uuid(), } = {}) => ({ id, email, name, createdAt, updatedAt, hashedPassword });
Эта функция позволяет легко создавать профили пользователей с фиктивными данными.
❯ Валидация
Для валидации поисковых строк (queries) и тел запросов (request bodies) мы будем использовать Zod. Обычно, для этого используется express-validator, но он плохо работает с TS, поскольку Express не умеет выводить типы структур данных.
Устанавливаем zod:
npm i zod
Создаем посредника для валидации:
import type { Request, Response } from 'express'; import type { ZodType } from 'zod'; import { ZodError } from 'zod'; export function createValidate(key: 'body' | 'query' | 'params') { return async function validate<T>( schema: ZodType<T>, request: Request, response: Response, ): Promise<T> { try { const result = await schema.parseAsync(request[key]); return result; } catch (error) { if (error instanceof ZodError) { response .status(400) .json({ message: 'Bad Request', errors: error.issues }); throw new Error('Validation failed'); } throw error; } }; } export const validateBody = createValidate('body'); export const validateQuery = createValidate('query'); export const validateParams = createValidate('params');
Функция createValidate принимает ключ и возвращает функцию для валидации тела запроса, поисковой строки или параметров запроса с помощью метода parseAsync схемы Zod.
Если вам интересно, в чем разница между body, query и params, вот краткое объяснение:
bodyсодержит данные, отправляемые в качестве полезной нагрузки (payload) запроса (часто используется с методами POST, PUT и др.) и обычно разбираемые с помощью посредника, такого какbody-parserqueryсодержит пары «ключ-значение» из поисковой строки URL (часть после?), часто используется для фильтрации или пагинацииparamsсодержит параметры роута, определенные в пути URL (например,idв/users/:id), используется для захвата определенных сегментов URL
Помните, я сказал, что express-validator плохо работает с TS? express-validator обычно используется так:
import express from 'express'; import { query } from 'express-validator'; const app = express(); app.use(express.json()); app.get('/hello', query('person').notEmpty(), (request, response) => { response.send(`Hello, ${request.query.person}!`); }); app.listen(3000);
В этом сниппете TS не знает, что request.query.person - это string, поскольку express-validator запускается во время выполнения, а система типов TS обладает информацией лишь о статических типах, предоставленных Express.
Но благодаря функции validateQuery, TS знает, что person является строкой.
Вот как можно использовать ее в коде:
// temp-validate-query-example.ts import express from 'express'; import { z } from 'zod'; import { validateQuery } from '../middleware/validate'; const app = express(); // Определяем схему Zod для параметров поисковой строки const helloQuerySchema = z.object({ person: z.string().min(1, { message: 'person is required' }), }); app.get('/hello', async (request, response, next) => { try { // Валидируем и парсим `query` с помощью нашего кастомного валидатора const query = await validateQuery(helloQuerySchema, request, response); // TS теперь знает, что `query.person` - это `string` response.send(`Hello, ${query.person}!`); } catch (error) { // Правильно обрабатываем ошибки (ошибки валидации уже отправлены клиенту) next(error); } }); app.listen(3000, () => { console.log('Server is running on port 3000'); });
❯ Куки
Еще одна вещь, которую должен уметь делать сервер - это читать куки (cookie). По умолчанию Express умеет добавлять куки в ответы, но читать их из запросов не может.
Устанавливаем соответствующего посредника:
npm i cookie-parser && npm i -D @types/cookie-parser
Добавляем его в приложение:
// src/app.ts import cookieParser from 'cookie-parser'; import type { Express } from 'express'; import express from 'express'; import { apiV1Router } from './routes.js'; export function buildApp(): Express { const app = express(); app.use(express.json()); // Посредник для чтения куки, содержащихся в запросе app.use(cookieParser()); app.use('/api/v1', apiV1Router); return app; }
Теперь любой запрос будет иметь объект request.cookies, содержащий куки, отправленные клиентом.
На этом первая часть туториала завершена. До встречи в следующей части.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
