Каждый раз, когда ваш API получает запрос, вы его валидируете. Каждый раз, когда функция получает аргумент из внешней системы, вы его проверяете. Делайте это вручную — и один endpoint накапливает больше кода валидации, чем бизнес-логики.

Это скрытый налог runtime contracts. Они нужны, потому что система типов лжёт: JSON over HTTP, поля базы данных и переменные окружения приходят как нетипизированные данные во время выполнения. Но реализовывать их вручную через if-проверки, написанные вручную, превращает простые функции в нечитаемые стены оборонительного кода.

Есть способ лучше.

Что на самом деле такое runtime contracts

Runtime contract — это гарантия, которую функция даёт относительно своих входных и выходных данных. Если передать валидные данные на вход, на выходе получатся валидные данные. Если нарушить contract, функция fail fast с чёткой ошибкой.

Это Design by Contract, и он существует со времён Eiffel. Идея не нова. Новым является инструментарий, который позволяет выражать contracts, не писая валидатор для каждого поля.

Ключевое наблюдение: contracts — это схемы, а не код. Схема описывает форму, ограничения и связи. Код, который проверяет всё это вручную, — это просто схема, написанная на неправильном языке.

Ловушка boilerplate

Вот как выглядит ручная валидация на практике. У вас есть функция, которая обрабатывает запрос на регистрацию пользователя:

function createUser(payload: unknown) {
  if (!payload || typeof payload !== "object") {
    throw new Error("Invalid payload");
  }
  if (!("email" in payload) || typeof (payload as any).email !== "string") {
    throw new Error("email is required and must be a string");
  }
  if (!(payload as any).email.includes("@")) {
    throw new Error("email must be valid");
  }
  if (!("age" in payload) || typeof (payload as any).age !== "number") {
    throw new Error("age is required and must be a number");
  }
  if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
    throw new Error("age must be an integer of at least 13");
  }
  if (
    "tags" in payload &&
    (!Array.isArray((payload as any).tags) ||
      !(payload as any).tags.every((t: any) => typeof t === "string"))
  ) {
    throw new Error("tags must be an array of strings");
  }
  // ... finally, the actual work
}

Это утомительно, чревато ошибками и неполно. Проверили ли вы, что email соответствует реальному формату email? Что age не NaN? Что элементы tags — непустые строки? Скорее всего, нет, потому что вы устали.

А это всего лишь одна функция. Умножьте на каждую boundary в вашей системе.

Как декларативные схемы заменяют boilerplate

Вместо того чтобы писать валидаторы, опишите форму и позвольте библиотеке применить её.

import { z } from "zod";

const UserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(13),
  tags: z.array(z.string().min(1)).optional(),
});

function createUser(payload: unknown) {
  const user = UserSchema.parse(payload);
  // user is typed as { email: string; age: number; tags?: string[] }
  // actual business logic goes here
}

Схема — это документация. Она же валидатор, определение типа и генератор сообщений об ошибках. Измените схему в одном месте — и contract обновится везде, где используется.

Это не магия. Библиотека обходит схему во время выполнения и применяет проверки. Разница в том, что проверки составляются из маленьких, переиспользуемых примитивов, а не выписываются вручную каждый раз.

Contracts на границах функций

Настоящая мощь появляется, когда схемы прикрепляются напрямую к входным и выходным данным функции. Вы хотите, чтобы contract находился на границе, а не просачивался в реализацию.

function withContract<TInput, TOutput>(
  inputSchema: z.ZodSchema<TInput>,
  outputSchema: z.ZodSchema<TOutput>,
  handler: (input: TInput) => TOutput
) {
  return (rawInput: unknown): TOutput => {
    const input = inputSchema.parse(rawInput);
    const output = handler(input);
    return outputSchema.parse(output);
  };
}

const UserResponseSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
});

const createUser = withContract(
  UserSchema,
  UserResponseSchema,
  (user) => {
    // zero validation code here
    return db.users.create(user);
  }
);

Теперь тело функции не содержит ни строчки кода валидации. Contract применяется на границе, и компилятор всё ещё знает типы внутри, потому что схема их выводит.

Где это даёт сбой: производительность, вывод типов и избыточная валидация

Декларативные схемы не бесплатны. Перед их внедрением стоит знать компромиссы.

Производительность — очевидная проблема. Парсинг сложной схемы медленнее нескольких if-проверок. На практике разница пренебрежимо мала для большинства API. Zod примерно в 10 раз медленнее проверок, написанных вручную, для простых объектов. Для большинства HTTP handlers это разница в микросекундах. Если вы валидируете миллионы записей в горячем цикле, сначала сделайте бенчмарк.

Пробелы в выводе типов тоже могут подвести. Выведенные типы из схем иногда строже или свободнее, чем те, что вы написали бы вручную. Возможно, придётся определить собственные типы и заставить схему соответствовать им, а не наоборот.

Избыточная валидация — тихий убийца. Не каждой функции нужен runtime contract. Внутренние функции, получающие данные, уже проверенные на границе API, не нуждаются в повторной проверке. Применяйте contracts на границах системы, а не в каждой вспомогательной функции.

Как внедрить это без rewrite codebase

Вам не нужна миграция типа big-bang. Начните с точек входа вашего API.

  1. Выберите одну внешне ориентированную функцию или route handler.
  2. Напишите схему для её входных данных.
  3. Замените ручную валидацию парсингом схемы.
  4. Позвольте схеме вывести TypeScript-тип. Удалите написанный вручную interface.

Сделайте это для одного endpoint, затем для одного сервиса, затем для одного домена. За неделю вы заметите, как сокращаются ваши тестовые наборы, потому что больше не нужно тестировать крайние случаи валидации, которые уже покрывает схема.

Если вы не используете TypeScript, паттерн переносится. В Python есть Pydantic. В Go — go-playground/validator. В Rust — validator. Принцип тот же: опишите contract, сгенерируйте проверки.

FAQ

Заменяют ли runtime contracts unit tests?

Нет. Они заменяют ту часть unit tests, которая проверяет валидацию входных данных. Вы всё ещё тестируете бизнес-логику, обработку ошибок и интеграционное поведение.

А как насчёт пользовательских правил валидации?

Каждая серьёзная библиотека схем поддерживает custom refinements. Используйте их умеренно. Если ваше правило длиннее пяти строк, оно, скорее всего, должно находиться в теле функции, а не в схеме.

Стоит ли валидировать и на выходе?

Да, для критических функций. Output contract ловит баги, когда ваша бизнес-логика производит некорректные данные. Дешевле упасть в источнике, чем позволить плохим данным распространиться в другой сервис.

Итог

Runtime contracts не подлежат обсуждению для систем, которые работают с нетипизированными данными. Единственный выбор — платить за них boilerplate или библиотекой схем.

Библиотека схем стоит вам зависимости и небольших накладных расходов на производительность. Boilerplate стоит вам поддерживаемости, корректности и рассудка.

Выбирайте зависимость.