Эта статья поможет вам создать приложение 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 в JS

  • start запускает скомпилированный код JS

  • dev запускает код 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). Фасад - это обертка сервиса, предоставляющая упрощенный интерфейс для сложной подсистемы.

Фасады полезны по двум причинам:

  1. Рост сопротивления поставщиков - фасады позволяют быстро оборачивать поставщиков (провайдеров). Например, переключение с Postgres на MongoDB путем одного изменения. Мы обновляем реализацию (структуру) фасада, а код, использующий фасад, остается прежним.

  2. Упрощение кода - фасад ограничивает 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-parser

  • query содержит пары «ключ-значение» из поисковой строки 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-канале

Комментарии (0)