Основная сложность при использовании 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 не предоставляет полноценного механизма вывода 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,
несмотря на дополнительные ограничения.
Используются несколько подходов для уменьшения расхождения между схемой и типами:
В экосистеме существуют инструменты, частично решающие проблему:
Joi при этом остаётся библиотекой, ориентированной прежде всего на runtime-валидацию, а не на типовую систему TypeScript.
При использовании Joi в сервисах или API важно явно типизировать слои обработки:
async function createUser(data: unknown): Promise<User> {
const user = validate<User>(userSchema, data);
return user;
}
Тип Promise<User> обеспечивает статическую
гарантию результата, но не проверяет корректность схемы.
Связка TypeScript и Joi строится на разделении ответственности:
Такой подход требует дисциплины в поддержании идентичности схем и типов, поскольку автоматической связки между слоями не предусмотрено.