История о том, как потратить два дня на многократное переписывание одного и того же кода.
Вступление
В рамках данной статьи опущу подробности про 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
. За что боролись на то и напоролись.
Соединить два решения в одно?
Где-то к этому моменту рабочий день подходил к логическому завершению. Я перевёл дух, выпил кофе и стёр эту порнографию к чертям. Надо меньше читать эти ваши интернеты! Настало время взять дробовик и пораскинуть мозгами:
- Объект должен формироваться с явными типами значений;
- Можно использовать дженерики, чтобы прокидывать типы в один интерфейс;
- Дженерики поддерживают типы по умолчанию;
- Конструкция
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()])
};
Компилится, на месте использования выглядит аккуратно и при отсутствии особых условий всегда устанавливает типы по умолчанию! Красота... ...на что я потратил два рабочих дня
Резюмирование
Какие выводы из всего этого можно сделать:
- Очевидно, я не научился находить ответы на вопросы. Наверняка при удачном запросе это решение (а то и ещё лучше) находится в первой 5ке ссылок поисковика;
- Переключиться на статическое мышление с динамического не так просто, гораздо чаще я просто забиваю на такое копошение;
- Дженерики - крутая штука. На хабре и стековерфлоу полно
велосипедовнеочевидных решений для построения сильной типизации...вне рантайма.
Что мы выиграли:
- При изменении интерфейса отваливается весь код, включая валидатор;
- В редакторе появились подсказки по именам свойств и типам значений объекта для написания валидатора;
- Отсутствие непонятных сторонних библиотек для тех же целей;
- Правила Joi будут применяться только там, где это нужно, в остальных случаях - дефолтные типы;
- Если кто-то захочет поменять тип значения какого-то свойства, то при правильной организации кода, он попадёт в то место, где вместе собраны все типы, связанные с этим свойством;
- Научились красиво и просто скрывать дженерики за абстракцией
type
, визуально разгружая код от монструзоных конструкций.
Мораль: Опыт бесценен, для остального есть карта "Мир".
Посмотреть, пощупать, запустить итоговый результат можно: https://repl.it/@Melodyn/Joi-by-interface
PS
Посмотреть на другие вариации решений по данной задаче можно в комментариях на Хабре