Создание собственных типов

Библиотека Joi предоставляет механизм расширения схем валидации через создание пользовательских типов. Этот подход используется в случаях, когда стандартных примитивов (string, number, object, array и других) недостаточно для описания предметной области. Пользовательские типы позволяют инкапсулировать правила проверки, преобразования данных и обработку ошибок внутри повторно используемых сущностей.

Механизм расширения реализуется через функцию Joi.extend, которая принимает описание нового типа, основанного на существующем или полностью кастомном поведении.


Базовая структура расширения

Создание нового типа начинается с вызова Joi.extend, где описывается:

  • имя типа
  • базовый тип (при наличии)
  • кастомные правила (rules)
  • логика валидации
  • преобразования входных данных
  • сообщения об ошибках

Пример минимальной структуры:

import Joi fr om 'joi';

const extendedJoi = Joi.extend({
  type: 'positiveInt',
  base: Joi.number(),
  messages: {
    'positiveInt.base': '"{{#label}}" должен быть положительным целым числом'
  },
  validate(value, helpers) {
    if (!Number.isInteger(value) || value <= 0) {
      return { value, errors: helpers.error('positiveInt.base') };
    }
  }
});

В этом примере создаётся новый тип positiveInt, основанный на number, но с дополнительным ограничением: значение должно быть положительным целым числом.


Определение базового типа

Поле base задаёт исходную схему, от которой наследуется новый тип. Это позволяет повторно использовать встроенные механизмы Joi, включая:

  • базовую проверку типа
  • встроенные методы (min, max, required)
  • преобразования значений

Пример:

const JoiExtended = Joi.extend({
  type: 'evenNumber',
  base: Joi.number(),
  validate(value, helpers) {
    if (value % 2 !== 0) {
      return { value, errors: helpers.error('evenNumber.base') };
    }
  },
  messages: {
    'evenNumber.base': 'Число должно быть чётным'
  }
});

Использование base снижает объём ручной проверки и позволяет интегрироваться с существующим API Joi.


Правила rules и расширение поведения

Более гибкий способ создания типов — использование rules. Каждый rule представляет отдельный метод, который может быть вызван в цепочке.

Структура rule:

rules: {
  ruleName: {
    method(args) {
      return this.$_addRule({ name: 'ruleName', args });
    },
    validate(value, helpers, args) {
      return value;
    }
  }
}

Пример: ограничение строки по количеству слов

const JoiExtended = Joi.extend({
  type: 'sentence',
  base: Joi.string(),
  rules: {
    maxWords: {
      method(lim it) {
        return this.$_addRule({ name: 'maxWords', args: { limit } });
      },
      args: [
        {
          name: 'limit',
          assert: (value) => typeof value === 'number',
          message: 'limit должен быть числом'
        }
      ],
      validate(value, helpers, args) {
        const wordCount = value.trim().split(/\s+/).length;

        if (wordCount > args.limit) {
          return helpers.error('sentence.maxWords', { limit: args.limit });
        }

        return value;
      }
    }
  },
  messages: {
    'sentence.maxWords': 'Количество слов не должно превышать {{#limit}}'
  }
});

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

const schema = JoiExtended.sentence().maxWords(5);

schema.validate('один два три четыре пять шесть');

Кастомная логика validate

Функция validate выполняется на уровне типа и позволяет:

  • проверять значения до применения rules
  • модифицировать данные
  • возвращать ошибки

Сигнатура:

validate(value, helpers)

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

const JoiExtended = Joi.extend({
  type: 'trimmedString',
  base: Joi.string(),
  validate(value, helpers) {
    if (typeof value !== 'string') {
      return { value, errors: helpers.error('string.base') };
    }

    return { value: value.trim() };
  }
});

В данном случае значение модифицируется до передачи в дальнейшие проверки.


Сообщения об ошибках

Пользовательские типы могут определять собственные сообщения через поле messages.

Поддерживается интерполяция переменных:

  • {{#label}} — имя поля
  • {{#value}} — текущее значение
  • пользовательские параметры из helpers.error

Пример:

messages: {
  'username.invalid': 'Имя пользователя "{{#value}}" недопустимо'
}

Передача параметров:

helpers.error('username.invalid', { value })

Работа с coerce (предобработка данных)

coerce позволяет преобразовывать входные данные до основной валидации.

Пример: преобразование строки в число

const JoiExtended = Joi.extend({
  type: 'numericString',
  base: Joi.string(),
  coerce(value, helpers) {
    const parsed = Number(value);

    if (!isNaN(parsed)) {
      return { value: parsed };
    }

    return { value };
  },
  validate(value, helpers) {
    if (typeof value !== 'number') {
      return { value, errors: helpers.error('numericString.base') };
    }
  },
  messages: {
    'numericString.base': 'Значение должно быть числом'
  }
});

Композиция и повторное использование

Пользовательские типы могут комбинироваться с другими схемами Joi:

const JoiExtended = Joi.extend({
  type: 'slug',
  base: Joi.string(),
  rules: {
    format: {
      validate(value, helpers) {
        const isValid = /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);

        if (!isValid) {
          return helpers.error('slug.format');
        }

        return value;
      }
    }
  },
  messages: {
    'slug.format': 'Неверный формат slug'
  }
});

const schema = JoiExtended.object({
  url: JoiExtended.slug().format().required()
});

Наследование расширений

Несколько расширений могут объединяться через последовательные вызовы extend:

const baseExtension = Joi.extend({
  type: 'baseType',
  base: Joi.any()
});

const finalExtension = baseExtension.extend({
  type: 'customType',
  base: baseExtension.baseType()
});

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


Валидация сложных доменных сущностей

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

  • идентификаторы (UUID, ULID)
  • бизнес-правила (статусы заказов, роли)
  • структурированные значения (телефон, email с дополнительными ограничениями)

Пример доменного типа:

const JoiExtended = Joi.extend({
  type: 'orderStatus',
  base: Joi.string().valid('new', 'processing', 'done', 'canceled'),
  rules: {
    notFinal: {
      validate(value, helpers) {
        if (value === 'done' || value === 'canceled') {
          return helpers.error('orderStatus.final');
        }
        return value;
      }
    }
  },
  messages: {
    'orderStatus.final': 'Изменение финального статуса запрещено'
  }
});

Поведение цепочек методов

Методы, добавленные через rules, сохраняют цепочный API Joi:

const schema = JoiExtended.sentence()
  .maxWords(10)
  .required()
  .messages({
    'any.required': 'Поле обязательно'
  });

Каждый вызов добавляет правило в очередь исполнения.


Внутренний порядок выполнения

При валидации пользовательского типа порядок следующий:

  1. coerce — преобразование данных
  2. base validation — базовая проверка
  3. validate — кастомная логика типа
  4. rules — последовательные правила
  5. встроенные методы Joi (required, min, max, и др.)

Понимание порядка важно при создании предсказуемых расширений.


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

Часто встречающиеся проблемы:

  • дублирование логики между validate и rules
  • чрезмерная модификация данных в coerce
  • отсутствие согласованных сообщений об ошибках
  • смешение доменной логики и инфраструктурной проверки

Рациональная структура предполагает:

  • coerce — только преобразование
  • validate — только базовые ограничения
  • rules — расширяемая логика
  • messages — централизованные ошибки

Интеграция с объектными схемами

Пользовательские типы интегрируются в объекты без дополнительных настроек:

const schema = Joi.object({
  username: JoiExtended.slug().required(),
  score: JoiExtended.evenNumber().min(0)
});

Такой подход позволяет строить полностью типизированные доменные модели на основе расширений Joi.