Извлечение типов из схем

Работа со схемами валидации часто приводит к необходимости синхронизации двух миров: runtime-валидации и статической типизации. В экосистеме JavaScript библиотека Joi традиционно решает задачу проверки данных в рантайме, тогда как TypeScript отвечает за описание структур на этапе компиляции. Разрыв между этими слоями становится источником рассинхронизации типов и схем.

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


Ограничения Joi в контексте типизации

Схемы Joi описываются декларативно, но их структура ориентирована на runtime-проверки:

const schema = Joi.object({
  id: Joi.number().integer().required(),
  name: Joi.string().min(3),
  tags: Joi.array().items(Joi.string())
});

На уровне TypeScript такая схема не имеет прямого отражения в типах. Основная проблема заключается в том, что:

  • Joi не хранит типовую информацию в форме, пригодной для компилятора TypeScript
  • API схемы не проектировался как типовой DSL
  • большинство методов возвращают объекты с цепочками вызовов, а не типы

Это приводит к необходимости внешней трансляции схемы в тип.


Ручное построение типов на основе схем

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

type User = {
  id: number;
  name?: string;
  tags?: string[];
};

Такой подход прост, но приводит к дублированию логики. Любое изменение схемы требует синхронного обновления типов.


Использование описания схемы через introspection

Joi предоставляет метод describe(), позволяющий получить структурное описание схемы:

const description = schema.describe();

Результат содержит дерево с типами узлов:

{
  "type": "object",
  "keys": {
    "id": { "type": "number" },
    "name": { "type": "string", "flags": { "presence": "optional" } }
  }
}

Это описание может использоваться как промежуточное представление для генерации типов.


Преобразование описания Joi в TypeScript-типы

Базовый принцип заключается в рекурсивном обходе структуры describe() и отображении типов:

Соответствие примитивов

  • stringstring
  • numbernumber
  • booleanboolean
  • anyany

Объекты

type FromJoiObject<T> = {
  [K in keyof T]: FromJoiType<T[K]>
}

Пример логики преобразования

type FromJoiType<T> =
  T extends { type: "string" } ? string :
  T extends { type: "number" } ? number :
  T extends { type: "boolean" } ? boolean :
  T extends { type: "array"; items: any } ? FromJoiType<T["items"]>[] :
  T extends { type: "object"; keys: infer K } ? {
    [P in keyof K]: FromJoiType<K[P]>
  } :
  unknown;

Обработка обязательности и опциональности

Joi использует флаги presence:

  • required
  • optional
  • forbidden

При преобразовании учитывается presence:

type OptionalIf<T, Cond> = Cond extends true ? T | undefined : T;

Пример:

type FieldFromJoi<T> =
  T["flags"]["presence"] extends "optional"
    ? FromJoiType<T> | undefined
    : FromJoiType<T>;

Массивы и вложенные структуры

Сложность возрастает при вложенных схемах:

Joi.array().items(
  Joi.object({
    id: Joi.number(),
    value: Joi.string()
  })
);

Типизация:

type Item = {
  id: number;
  value: string;
};

type Result = Item[];

Рекурсивный алгоритм должен учитывать:

  • вложенные массивы
  • объекты внутри массивов
  • массивы внутри объектов

Альтернативы через alternatives() и union-типы

Конструкция alternatives() в Joi соответствует union-типам TypeScript:

Joi.alternatives().try(
  Joi.string(),
  Joi.number()
);

Отображение:

type Result = string | number;

При генерации типов требуется агрегировать все ветви try().


Использование обёрток и утилитных типов

Для уменьшения ручной работы применяются вспомогательные утилиты:

type InferJoi<T> = T extends { describe: () => infer D }
  ? FromJoiType<D>
  : never;

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

type User = InferJoi<typeof userSchema>;

Однако такие конструкции ограничены тем, что describe() возвращает runtime-значение, а не статический тип.


Практика разделения схем и типов

В ряде архитектур применяется принцип:

  • Joi-схема является источником истины для runtime
  • TypeScript-типы выводятся вручную или генерируются скриптом
  • генерация выполняется на этапе build-time

Такой подход снижает связность и уменьшает риск рассинхронизации.


Инструменты генерации типов

Существуют сторонние решения, работающие поверх Joi:

  • генераторы TypeScript из схем
  • CLI-инструменты для преобразования describe()
  • плагины для сборщиков

Часто используется подход, при котором схема экспортируется как JSON через describe() и далее обрабатывается генератором типов.


Проблемные случаи преобразования

Некоторые конструкции Joi плохо поддаются строгой типизации:

  • any() — теряет информацию о типе
  • custom() — произвольная логика
  • зависимости через when() — условная структура
  • динамические схемы

В таких случаях результатом обычно становится unknown или расширенный union.


Рекурсивные типы и ограничения TypeScript

При глубокой вложенности схем возникают ограничения компилятора:

  • глубина рекурсии типов
  • сложность вычисления conditional types
  • увеличение времени компиляции

Для сложных схем часто применяется упрощение типов или ограничение глубины вывода.


Общая стратегия извлечения типов

Типовой процесс построения выглядит следующим образом:

  1. Получение схемы Joi
  2. Вызов describe() для получения AST-подобной структуры
  3. Рекурсивный разбор узлов
  4. Отображение узлов в примитивы TypeScript
  5. Формирование финального типа

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