Интеграция с типами TypeScript

Согласование схемы Joi и статических типов

Основная сложность при использовании Joi в связке с TypeScript заключается в отсутствии автоматической синхронизации между схемой валидации и статическими типами. Joi описывает структуру данных в рантайме, тогда как TypeScript работает на этапе компиляции. Это приводит к необходимости поддерживать две сущности параллельно: схему валидации и тип данных.

Типичная проблема проявляется в рассинхронизации: изменение Joi-схемы не отражается автоматически в интерфейсах TypeScript, что создаёт риск ошибок при компиляции без явных предупреждений.


Базовое определение типов и схем

Наиболее распространённый подход заключается в явном описании интерфейса данных и последующем создании Joi-схемы на его основе.

import Joi from 'joi';

interface User {
  id: number;
  name: string;
  email: string;
  age?: number;
}

const userSchema = Joi.object<User>({
  id: Joi.number().required(),
  name: Joi.string().min(3).required(),
  email: Joi.string().email().required(),
  age: Joi.number().optional()
});

Такой подход формально связывает структуру данных и схему, однако TypeScript не проверяет соответствие Joi-описания интерфейсу. Интерфейс остаётся независимым источником истины.


Ограничения типизации Joi

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

const result = userSchema.validate(data);
// result.value имеет тип any по умолчанию

Даже при использовании generic-параметров тип результата не становится полностью точным, поскольку Joi ориентирован на динамическую проверку.


Явное приведение типов результата валидации

Наиболее практичный вариант — ручное связывание результата валидации с интерфейсом:

const { value, error } = userSchema.validate(data);

if (error) {
  throw error;
}

const user: User = value;

Здесь TypeScript-типизация обеспечивается на уровне присваивания, а не на уровне Joi.


Обёртка для типизированной валидации

Для уменьшения повторяющегося кода применяется обобщённая функция:

function validate<T>(schema: Joi.Schema, data: unknown): T {
  const { value, error } = schema.validate(data);

  if (error) {
    throw error;
  }

  return value as T;
}

Использование:

const user = validate<User>(userSchema, data);

Такой подход централизует приведение типов, но переносит ответственность за корректность соответствия схемы и интерфейса на разработчика.


Типизация вложенных структур

При работе со сложными объектами с вложенными структурами необходимо синхронизировать интерфейсы рекурсивно.

interface Address {
  city: string;
  zip: string;
}

interface User {
  id: number;
  name: string;
  address: Address;
}
const userSchema = Joi.object<User>({
  id: Joi.number().required(),
  name: Joi.string().required(),
  address: Joi.object({
    city: Joi.string().required(),
    zip: Joi.string().required()
  }).required()
});

Здесь типизация вложенного объекта повторяется вручную, поскольку Joi не выводит вложенные TypeScript-типы автоматически.


Массивы и типизированные элементы

Типизация массивов требует отдельного описания элемента и его структуры:

interface User {
  id: number;
  tags: string[];
}
const schema = Joi.object<User>({
  id: Joi.number().required(),
  tags: Joi.array().items(Joi.string()).required()
});

TypeScript не связывает items() с типом массива автоматически, поэтому соответствие также поддерживается вручную.


Частичные и опциональные поля

При работе с optional полями важно синхронизировать поведение Joi и TypeScript:

interface User {
  id: number;
  nickname?: string;
}
const schema = Joi.object<User>({
  id: Joi.number().required(),
  nickname: Joi.string().optional()
});

Несоответствие между required() и optional() может привести к логическим ошибкам, не выявляемым компилятором TypeScript.


Пользовательские валидаторы и расширение типов

При использовании кастомных правил через .custom() тип результата не уточняется автоматически:

const schema = Joi.string().custom((value, helpers) => {
  if (!value.startsWith('id_')) {
    return helpers.error('string.invalid');
  }
  return value;
});

TypeScript продолжает воспринимать результат как string, несмотря на дополнительные ограничения.


Стратегии минимизации рассинхронизации

Используются несколько подходов для уменьшения расхождения между схемой и типами:

  • Единый источник структуры данных через интерфейсы TypeScript
  • Ручное дублирование структуры в Joi-схемах
  • Централизованная функция валидации с generic-типами
  • Строгая дисциплина синхронизации изменений между слоями

Альтернативные подходы к типизации

В экосистеме существуют инструменты, частично решающие проблему:

  • генерация TypeScript типов из схем (например, через внешние генераторы)
  • переход на библиотеки с нативной типизацией (например, Zod-подобный подход)
  • использование схем как основного источника истины с генерацией интерфейсов

Joi при этом остаётся библиотекой, ориентированной прежде всего на runtime-валидацию, а не на типовую систему TypeScript.


Типизация результатов в асинхронных потоках

При использовании Joi в сервисах или API важно явно типизировать слои обработки:

async function createUser(data: unknown): Promise<User> {
  const user = validate<User>(userSchema, data);
  return user;
}

Тип Promise<User> обеспечивает статическую гарантию результата, но не проверяет корректность схемы.


Сводная модель взаимодействия TypeScript и Joi

Связка TypeScript и Joi строится на разделении ответственности:

  • Joi отвечает за проверку данных в рантайме
  • TypeScript отвечает за структуру данных на этапе компиляции
  • Синхронизация между ними выполняется вручную

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