История о том, как потратить два дня на многократное переписывание одного и того же кода.
Вступление
В рамках данной статьи опущу подробности про 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
Комментарии (15)
vintage
01.05.2019 10:05type UserKeys<T> = { [key in keyof T]; }
Этот код эквивалентен такому:
type UserKeys<T> = { [key in keyof T] : any; }
Так что не удивительно, что вы смогли присвоить значениям любую балалайку. Включите "strict" режим компилятора, чтобы не напарываться на такие ошибки.
Смотрите, как это делается по хорошему:
На функцияхtype Val<Type = any> = (val: Type) => Type function Str(val: string) { if (typeof val !== 'string') throw new Error('Not a string') return val } function Int(val: number) { if (typeof val !== 'number') throw new Error('Not a number') if( Math.floor(val) !== val ) throw new Error('Not an integer') return val } function Alt<Sub extends Val[]>(sub: Sub) { return (val: ReturnType<Sub[number]>) => { const errors = [] as String[] for (const type of sub) { try { type(val) return val } catch (error) { errors.push( error.message ) } } throw new Error( errors.join(' and ') ) } } function Rec<Sub extends Record<string, Val>>(sub: Sub) { return (val: { [key in keyof Sub]: ReturnType<Sub[key]> }) => { for (const field in sub) { sub[field](val[field]) } return val } } const User = Rec({ name: Str, age: Int, phone: Alt([ Str , Int ]) }) const evan = User({ name: 'Evan', age: 32, phone: 791234567890, }) const john = User({ name: 'Evan', age: 32, phone: 791234567890.1, // Not a string and Not an integer }) const mary = User({ name: 'Evan', age: 32, phone: false, // Type 'false' is not assignable to type 'string | number' })
melodyn Автор
01.05.2019 10:16Да, ещё можно использовать
[key in keyof T] : T[key]
, чтобы более явно задать тип значения, но я забыл про это пока писал статью, вроде, там не было профита)
С классами интересно, вроде, не встречал подобного в статьях пока что.
keenondrums
01.05.2019 10:07Попробуйте github.com/typestack/class-validator вместе с github.com/typestack/class-transformer
melodyn Автор
01.05.2019 10:19Да, спасибо за ссылки, стоило их включить в статью, наверное. Мне подсказывали про эти библиотеки, но возможности их пока не изучал. Пока было два дня перед праздниками, решил больше надавить на спортивный интерес, чем на сторонние либы :)
LEXA_JA
01.05.2019 11:10В порядке бреда можно полностью вывести тип валидатора из произвольного типа
type Username = string; type Age = number; type PhoneNumber = string | number; interface IUser { name: Username; age: Age; phone: PhoneNumber; } type UserJoi = JoiValidtor<IUser>; /** * name: Joi.StringSchema; * age: Joi.NumberSchema; * phone: Joi.AlternativesSchema; */
JoiValidtortype ValidatorGuard<T, TARGET_TYPE, VALIDATOR_TYPE> = T extends TARGET_TYPE ? VALIDATOR_TYPE : never; type JoiRecord<T> = { [K in keyof T]: JoiValidtor<T[K]> } type JoiValidtor<T> = IsUnion<T> extends true ? Joi.AlternativesSchema : ValidatorGuard<T, string, Joi.StringSchema> | ValidatorGuard<T, number, Joi.NumberSchema> | ValidatorGuard<T, object, JoiRecord<T>>; // Дописать сюда и все остальные типы
reforms
04.05.2019 18:10Представляю вам другой способ, так сказать 'наизнанку мысли'. Тем не менее он более лаконичен и прост в понимании, со своими минусами, но как же без них
type TS<K extends boolean, SimpleType, JoiType> = K extends true ? SimpleType : JoiType; class EtalonUser<K extends boolean = true> { readonly name: TS<K, string, Joi.StringSchema> = null; readonly age: TS<K, number, Joi.NumberSchema> = null; readonly phone: TS<K, string | number, Joi.AlternativesSchema> = null; } // Обратите внимание, как красиво получаем нужный контракт пользователя interface IUser extends EtalonUser { } // Обратите внимание, как красиво получаем нужный контракт валидации пользователя interface IJoiUserSchema extends EtalonUser<false> { } const User: IUser = { name: "Evg Pal", age: 33, phone: "+79151231231" } const UserSchema: IJoiUserSchema = { name: Joi.string(), age: Joi.number(), phone: Joi.alternatives([Joi.string(), Joi.number()]) }
melodyn Автор
04.05.2019 20:51Да, решение тоже интересное и хорошо, как минимум своей краткостью и лишь одной условной конструкцией. Почему класс, а не интерфейс и типы? Кажется, такое решение тоже достаточно рабочее:
type TS<K extends boolean, SimpleType, JoiType> = K extends true ? SimpleType : JoiType; interface EtalonUser<K extends boolean = true> { name: TS<K, string, Joi.StringSchema>; age: TS<K, number, Joi.NumberSchema>; phone: TS<K, string | number, Joi.AlternativesSchema>; } type IUser = EtalonUser; type IJoiUserSchema = EtalonUser<false>; const User: IUser = { name: "Evg Pal", age: 33, phone: "+79151231231" }; const UserSchema: IJoiUserSchema = { name: Joi.string(), age: Joi.number(), phone: Joi.alternatives([Joi.string(), Joi.number()]) };
reforms
05.05.2019 10:15Почему класс, а не интерфейс и типы?
Это след экспериментов, хотел подружить нормальное описание данных в классе с декораторами на типы Joi. Что-то слету у меня не получилось. А так конечно уместен интерфейс (тип), а не класс :)
zoonman
Проблема в том, что смешиваются понятия типизации и валидации данных.
С одной стороны они пересекаются, но с другой — нет.
Например, может существовать бизнес-логика допускающая запись телефона числом. Затем эта логика меняется и телефоны начинают храниться в строковом виде. По-хорошему нужно производить миграцию и уходить от чисел, т.е. избавляться от вариативности в типах.
Просто надо исходить из позиции, что данные обязательно должны реализовывать интерфейс, иначе теряется смысл использования TypeScript. Ну а валидатор, в свою очередь, должен ужесточать реализацию путем накладывания дополнительных ограничений.
Да и писать тесты до описания данных выгляд для меня очень странно. Обычно модель данных и ограничений рождается до написания функционала на этапе проектирования. Если у вас что-то не так, значит имеются пробелы в процессе разработки. Опять же, если тесты не проверяют код на соответстве требованиям, то это всплывает уже позже, на этапе эксплуатации.
melodyn Автор
> логика меняется и телефоны начинают храниться в строковом виде
Думаю, в контексте чисто бэка, такие вопросы почти не актуальны. В самом простом случае, у нас хранится строка (или числа) по регулярке 79[0-9]{9}, а как она будет отражена на каком-то фронте — без разницы. В статью я воткнул его просто для введения AlternativeSchema.
Насчёт тестов и интерфейсов, там, скорее, обыгрывается шуточный спор на тему «что должно быть раньше». В силу небольшого опыта, пока не имею своего обоснованного мнения на этот счёт.
zoonman
Как раз в бэкенде такие вопросы частенько актуальны, особенно если вы выходите за переделы Typescript и начинаете работать с БД или другими сервисами через ProtoBuf к примеру. Опять же работа с разного рода React Native тоже подразумевает достаточно четкий контроль типов.