Pull to refresh

Comments 18

Проблема в том, что смешиваются понятия типизации и валидации данных.
С одной стороны они пересекаются, но с другой — нет.
Например, может существовать бизнес-логика допускающая запись телефона числом. Затем эта логика меняется и телефоны начинают храниться в строковом виде. По-хорошему нужно производить миграцию и уходить от чисел, т.е. избавляться от вариативности в типах.
Просто надо исходить из позиции, что данные обязательно должны реализовывать интерфейс, иначе теряется смысл использования TypeScript. Ну а валидатор, в свою очередь, должен ужесточать реализацию путем накладывания дополнительных ограничений.
Да и писать тесты до описания данных выгляд для меня очень странно. Обычно модель данных и ограничений рождается до написания функционала на этапе проектирования. Если у вас что-то не так, значит имеются пробелы в процессе разработки. Опять же, если тесты не проверяют код на соответстве требованиям, то это всплывает уже позже, на этапе эксплуатации.
> логика меняется и телефоны начинают храниться в строковом виде

Думаю, в контексте чисто бэка, такие вопросы почти не актуальны. В самом простом случае, у нас хранится строка (или числа) по регулярке 79[0-9]{9}, а как она будет отражена на каком-то фронте — без разницы. В статью я воткнул его просто для введения AlternativeSchema.

Насчёт тестов и интерфейсов, там, скорее, обыгрывается шуточный спор на тему «что должно быть раньше». В силу небольшого опыта, пока не имею своего обоснованного мнения на этот счёт.
Как раз в бэкенде такие вопросы частенько актуальны, особенно если вы выходите за переделы Typescript и начинаете работать с БД или другими сервисами через ProtoBuf к примеру. Опять же работа с разного рода React Native тоже подразумевает достаточно четкий контроль типов.
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 — это уже без меня :-)

Да, ещё можно использовать [key in keyof T] : T[key], чтобы более явно задать тип значения, но я забыл про это пока писал статью, вроде, там не было профита)


С классами интересно, вроде, не встречал подобного в статьях пока что.

Да, спасибо за ссылки, стоило их включить в статью, наверное. Мне подсказывали про эти библиотеки, но возможности их пока не изучал. Пока было два дня перед праздниками, решил больше надавить на спортивный интерес, чем на сторонние либы :)

В порядке бреда можно полностью вывести тип валидатора из произвольного типа


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;
 */

JoiValidtor
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>>;
// Дописать сюда и все остальные типы

Черная магия isUnion

честно подсмотрено здесь.


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
UFO just landed and posted this here

Я думаю, переименовать в "Валидация по TypeScript interface с использованием Joi". Так будет более семантическо верно, согласны?

UFO just landed and posted this here
Исправил, спасибо за замечание!

Также, добавил в статью то, о чём общался с комментаторами выше.
Представляю вам другой способ, так сказать 'наизнанку мысли'. Тем не менее он более лаконичен и прост в понимании, со своими минусами, но как же без них
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. Что-то слету у меня не получилось. А так конечно уместен интерфейс (тип), а не класс :)
В процессе изучения подобных возможностей всё равно пришёл обратно к своему варианту, решил не париться и сделать как планировал, и всё получилось. Немного застопорило помещение интерфейсного объекта в Joi.object, но это тоже решилось (обновил статью).

Рад что мне не попадалось такое чудо в проектах, бо это же жесть) Чем декораторы не угодили?

Sign up to leave a comment.

Articles