Comments 18
С одной стороны они пересекаются, но с другой — нет.
Например, может существовать бизнес-логика допускающая запись телефона числом. Затем эта логика меняется и телефоны начинают храниться в строковом виде. По-хорошему нужно производить миграцию и уходить от чисел, т.е. избавляться от вариативности в типах.
Просто надо исходить из позиции, что данные обязательно должны реализовывать интерфейс, иначе теряется смысл использования TypeScript. Ну а валидатор, в свою очередь, должен ужесточать реализацию путем накладывания дополнительных ограничений.
Да и писать тесты до описания данных выгляд для меня очень странно. Обычно модель данных и ограничений рождается до написания функционала на этапе проектирования. Если у вас что-то не так, значит имеются пробелы в процессе разработки. Опять же, если тесты не проверяют код на соответстве требованиям, то это всплывает уже позже, на этапе эксплуатации.
Думаю, в контексте чисто бэка, такие вопросы почти не актуальны. В самом простом случае, у нас хранится строка (или числа) по регулярке 79[0-9]{9}, а как она будет отражена на каком-то фронте — без разницы. В статью я воткнул его просто для введения AlternativeSchema.
Насчёт тестов и интерфейсов, там, скорее, обыгрывается шуточный спор на тему «что должно быть раньше». В силу небольшого опыта, пока не имею своего обоснованного мнения на этот счёт.
type 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'
})
abstract class Val {
abstract from<Type>(val: unknown): unknown
}
class Str extends Val {
from(val: string) {
if (typeof val !== 'string') throw new Error('Not a string')
return val
}
}
class Int extends Val {
from(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
}
}
class Alt<Sub extends Val[]> extends Val {
constructor(public sub: Sub) { super() }
from(val: ReturnType<Sub[number]['from']>) {
const errors = [] as String[]
for (const type of this.sub) {
try {
type.from(val)
return val
} catch (error) {
errors.push( error.message )
}
}
throw new Error( errors.join(' and ') )
}
}
class Rec<Sub extends Record<string, Val>> extends Val {
constructor(public sub: Sub) { super() }
from(val: { [key in keyof Sub]: ReturnType<Sub[key]['from']> }) {
for (const field in this.sub) {
this.sub[field].from(val[field])
}
return val
}
}
const User = new Rec({
name: new Str,
age: new Int,
phone: new Alt([ new Str , new Int ])
})
const evan = User.from({
name: 'Evan',
age: 32,
phone: 791234567890,
})
const john = User.from({
name: 'Evan',
age: 32,
phone: 791234567890.1, // Not a string and Not an integer
})
const mary = User.from({
name: 'Evan',
age: 32,
phone: false, // Type 'false' is not assignable to type 'string | number'
})
Как прикрутить это к Joy — это уже без меня :-)
Да, спасибо за ссылки, стоило их включить в статью, наверное. Мне подсказывали про эти библиотеки, но возможности их пока не изучал. Пока было два дня перед праздниками, решил больше надавить на спортивный интерес, чем на сторонние либы :)
В порядке бреда можно полностью вывести тип валидатора из произвольного типа
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;
*/
type 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>>;
// Дописать сюда и все остальные типы
честно подсмотрено здесь.
type UnionToIoF<U> =
(U extends any ? (k: (x: U) => void) => void : never) extends
((k: infer I) => void) ? I : never
type UnionPop<U> = UnionToIoF<U> extends { (a: infer A): void; } ? A : never;
type Prepend<U, T extends any[]> =
((a: U, ...r: T) => void) extends (...r: infer R) => void ? R : never;
type UnionToTupleRecursively<Union, Result extends any[]> = {
1: Result;
0: UnionToTupleRecursively_<Union, UnionPop<Union>, Result>;
}[[Union] extends [never] ? 1 : 0];
type UnionToTupleRecursively_<Union, Element, Result extends any[]> =
UnionToTupleRecursively<Exclude<Union, Element>, Prepend<Element, Result>>;
type UnionToTuple<U> = UnionToTupleRecursively<U, []>;
type IsUnion<T> = UnionToTuple<T> extends [any, any, ...any[]] ? true : false
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()])
}
Да, решение тоже интересное и хорошо, как минимум своей краткостью и лишь одной условной конструкцией. Почему класс, а не интерфейс и типы? Кажется, такое решение тоже достаточно рабочее:
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()])
};
Почему класс, а не интерфейс и типы?
Это след экспериментов, хотел подружить нормальное описание данных в классе с декораторами на типы Joi. Что-то слету у меня не получилось. А так конечно уместен интерфейс (тип), а не класс :)
Рад что мне не попадалось такое чудо в проектах, бо это же жесть) Чем декораторы не угодили?
Валидация по TypeScript interface с использованием Joi