JavaScript
TypeScript
May 1

Валидация по TypeScript interface с использованием Joi

История о том, как потратить два дня на многократное переписывание одного и того же кода.


Joi & TypeScript. A love story


Вступление


В рамках данной статьи опущу подробности про Hapi, Joi, роутинг и validate: { payload: ... }, подразумевая, что вы уже понимаете о чём речь, как и терминологию, а-ля "интерфейсы", "типы" и тому подобное. Расскажу лишь о пошаговой, не самой удачной стратегии, своего обучения этим вещам.


Немного предыстории


Сейчас я единственный backend разработчик (именно, пишущий код) на проекте. Функциональность — не суть, но ключевая сущность — это довольно длинная анкета с личными данными. Скорость работы и качество кода завязано на моём малом опыте самостоятельной работы над проектами с нуля, ещё более малом опыте работы с JS (всего 4й месяц) и попутно, очень криво-косо, пишу на TypeScript (далее — TS). Сроки сжаты, булки сжаты, постоянно прилетают правки и получается сначала писать код бизнес-логики, а потом сверху интерфейсы. Тем не менее, технический долг способен догнать и настучать по шапке, что, примерно, с нами и случилось.


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


Проблема


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


  • Первый Нулевой шаг хорошего разработчика: описать данные написать тесты;
  • Первый шаг: написать тесты описать данные;
  • ну и так далее.

Допустим, на этот код уже написаны тесты, осталось описать данные:


interface IUser {
  name: string; 
  age: number;
  phone: string | number;
}

const aleg: IUser = {
  name: 'Aleg',
  age: 45,
  phone: '79001231212'
};

Чтож, тут всё понятно и предельно просто. Весь этот код, как мы помним, на бэкэнде, а точнее, в api, то есть пользователь создаётся на основе данных, которые пришли по сети. Таким образом, нам нужно сделать валидацию входящих данных и поможет в этом Joi:


const joiUserValidator = {
  name: Joi.string(),
  age: Joi.number(),
  phone: Joi.alternatives([Joi.string(), Joi.number()])
};

Решение "в лоб" готово. Очевидный минус такого подхода — валидатор полностью оторван от интерфейса. Если в процессе жизни приложения изменятся / добавятся поля или поменяется их тип, то данное изменение надо будет вручную отследить и указать в валидаторе. Думаю, таких ответственных разработчиков не будет до тех пор, пока что-то не упадёт. Кроме того, в нашем проекте, анкета состоит из 50+ полей на трёх уровнях вложенности и разбираться в этом крайне сложно, даже зная всё наизусть.


Просто указать const joiUserValidator: IUser мы не можем, потому что Joi использует свои типы данных, что порождает при компиляции ошибки вида Type 'NumberSchema' is not assignable to type 'number'. Но ведь должен быть способ выполнить валидацию по интерфейсу?
Я вышел в интернет с таким вопросом


Возможно, я неправильно гуглил, или плохо изучал ответы, но все решения сводились к либе extractTypes и каким-то лютым велосипедам, типа такого:


type ValidatedValueType<T extends joi.Schema> = T extends joi.StringSchema
  ? string
  : T extends joi.NumberSchema
    ? number
    : T extends joi.BooleanSchema
      ? boolean
      : T extends joi.ObjectSchema ? ValidatedObjectType<T> : 
      /* ... more schemata ... */ never;

Решение


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


Почему бы нет. Когда я вопрошал к людям со своей задачей, то получил в одном из ответов, а позже, и тут, в комментариях (спасибо keenondrums ), ссылки на данные библиотеки:
https://github.com/typestack/class-validator
https://github.com/typestack/class-transformer


Однако, был интерес разобраться самому, понять лучше работу TS, да и ничего не поджимало решить задачу сиюминутно.


Получить все свойства


Поскольку со статикой ранее дел я не имел, вышеуказанный код открыл Америку в плане применения тернарных операторов в типах. К счастью, применить его в проекте не удалось. Зато нашёл другой интересный велосипед:


interface IUser {
  name: string; 
  age: number;
  phone: string | number;
}

type UserKeys<T> = {
  [key in keyof T];
}

const evan: UserKeys<IUser> = {
  name: 'Evan',
  age: 32,
  phone: 791234567890
};

const joiUser: UserKeys<IUser> = {
  name: Joi.string(),
  age: Joi.number(),
  phone: Joi.alternatives([Joi.string(), Joi.number()])
};

TypeScript при довольно хитрых и загадочных условиях позволяет получить, например, ключи из интерфейса, словно это нормальный JS-объект, правда, только в конструкции type и через key in keyof T и только через дженерики. В результате работы типа UserKeys, у всех объектов, реализующих интерфейсы, должен быть одинаковый набор свойств, но при этом типы значений могут быть произвольные. Это включает подсказки в IDE, но всё ещё не даёт однозначно обозначить типы значений.


Здесь есть ещё один интересный кейс, который не смог использовать. Возможно, вы подскажете зачем это нужно (хотя я частично догадываюсь, не хватает прикладного примера):


interface IUser {
  name: string; 
  age: number;
  phone: string | number;
}

interface IUserJoi {
  name: Joi.StringSchema,
  age: Joi.NumberSchema,
  phone: Joi.AlternativesSchema
}

type UserKeys<T> = {
  [key in keyof T]: T[key];
}

const evan: UserKeys<IUser> = {
  name: 'Evan',
  age: 32,
  phone: 791234567890
};

const userJoiValidator: UserKeys<IUserJoi> = {
  name: Joi.string(),
  age: Joi.number(),
  phone: Joi.alternatives([Joi.string(), Joi.number()])
};

Использовать вариативные типы


Можно явно задать типы, а используя "ИЛИ" и извлечение свойств, получить локально работоспособный код:


type TString = string | Joi.StringSchema;
type TNumber = number | Joi.NumberSchema;
type TStdAlter = TString | TNumber;
type TAlter = TStdAlter | Joi.AlternativesSchema;

export interface IUser {
  name: TString;
  age: TNumber;
  phone: TAlter;
}

type UserKeys<T> = {
  [key in keyof T];
}

const olex: UserKeys<IUser> = {
  name: 'Olex',
  age: 67,
  phone: '79998887766'
};

const joiUser: UserKeys<IUser> = {
  name: Joi.string(),
  age: Joi.number(),
  phone: Joi.alternatives([Joi.string(), Joi.number()])
};

Проблема этого кода проявляется когда мы хотим забрать валидный объект, например, из базы, то есть TS заранее не знает какого типа данные будут — простые или Joi. Это может вызвать ошибку при попытке выполнить математические операции с полем, которое ожидается как number:


const someUser: IUser = getUserFromDB({ name: 'Aleg' });
const someWeirdMath = someUser.age % 10; // error TS2362: The left-hand side of an arithmetic operation must be of type'any', 'number', 'bigint' or an enum type

Данная ошибка приходит из Joi.NumberSchema потому что возраст может быть не только number. За что боролись на то и напоролись.


Соединить два решения в одно?


Где-то к этому моменту рабочий день подходил к логическому завершению. Я перевёл дух, выпил кофе и стёр эту порнографию к чертям. Надо меньше читать эти ваши интернеты! Настало время взять дробовик и пораскинуть мозгами:


  1. Объект должен формироваться с явными типами значений;
  2. Можно использовать дженерики, чтобы прокидывать типы в один интерфейс;
  3. Дженерики поддерживают типы по умолчанию;
  4. Конструкция type явно способна на что-то ещё.

Пишем интерфейс-дженерик с типами по умолчанию:


interface IUser
<
  TName = string,
  TAge = number,
  TAlt = string | number
> {
  name: TName; 
  age: TAge;
  phone: TAlt;
}

Для Joi можно было бы создать второй интерфейс, наследовав основной таким образом:


interface IUserJoi extends IUser
<
  Joi.StringSchema,
  Joi.NumberSchema,
  Joi.AlternativesSchema
> {}

Недостаточно хорошо, ведь следующий разработчик может с лёгким сердцем расширить IUserJoi или что похуже. Более ограниченный вариант получить похожее поведение:


type IUserJoi = IUser<Joi.StringSchema, Joi.NumberSchema, Joi.AlternativesSchema>;

Пробуем:


const aleg: IUser = {
  name: 'Aleg',
  age: 45,
  phone: '79001231212'
};

const joiUser: IUserJoi = {
  name: Joi.string(),
  age: Joi.number(),
  phone: Joi.alternatives([Joi.string(), Joi.number()])
};

UPD:
Для оборачивания в Joi.object пришлось побороться с ошибкой TS2345 и самым простым решение оказался as any. Думаю, это не критичное допущение, ведь выше объект всё равно по интерфейсу.


const joiUserInfo = {
  info: Joi.object(joiUser as any).required()
};

Компилится, на месте использования выглядит аккуратно и при отсутствии особых условий всегда устанавливает типы по умолчанию! Красота…
печаль-беда
… на что я потратил два рабочих дня


Резюмирование


Какие выводы из всего этого можно сделать:


  1. Очевидно, я не научился находить ответы на вопросы. Наверняка при удачном запросе это решение (а то и ещё лучше) находится в первой 5ке ссылок поисковика;
  2. Переключиться на статическое мышление с динамического не так просто, гораздо чаще я просто забиваю на такое копошение;
  3. Дженерики — крутая штука. На хабре и стековерфлоу полно велосипедов неочевидных решений для построения сильной типизации… вне рантайма.

Что мы выиграли:


  1. При изменении интерфейса отваливается весь код, включая валидатор;
  2. В редакторе появились подсказки по именам свойств и типам значений объекта для написания валидатора;
  3. Отсутствие непонятных сторонних библиотек для тех же целей;
  4. Правила Joi будут применяться только там, где это нужно, в остальных случаях — дефолтные типы;
  5. Если кто-то захочет поменять тип значения какого-то свойства, то при правильной организации кода, он попадёт в то место, где вместе собраны все типы, связанные с этим свойством;
  6. Научились красиво и просто скрывать дженерики за абстракцией type, визуально разгружая код от монструзоных конструкций.

Мораль: Опыт бесценен, для остального есть карта "Мир".


Посмотреть, пощупать, запустить итоговый результат можно:
https://repl.it/@Melodyn/Joi-by-interface
туть

+12
2.8k 27
Comments 17
Top of the day