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


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()])
};

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


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


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


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

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


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

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


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

Комментарии (15)


  1. zoonman
    01.05.2019 07:41

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


    1. melodyn Автор
      01.05.2019 21:02

      > логика меняется и телефоны начинают храниться в строковом виде

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

      Насчёт тестов и интерфейсов, там, скорее, обыгрывается шуточный спор на тему «что должно быть раньше». В силу небольшого опыта, пока не имею своего обоснованного мнения на этот счёт.


      1. zoonman
        02.05.2019 05:33

        Как раз в бэкенде такие вопросы частенько актуальны, особенно если вы выходите за переделы Typescript и начинаете работать с БД или другими сервисами через ProtoBuf к примеру. Опять же работа с разного рода React Native тоже подразумевает достаточно четкий контроль типов.


  1. vintage
    01.05.2019 10:05

    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'
    })


    1. melodyn Автор
      01.05.2019 10:16

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


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


  1. keenondrums
    01.05.2019 10:07

    1. melodyn Автор
      01.05.2019 10:19

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


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

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


  1. 183614956
    01.05.2019 20:10

    Заголовок больно читать. Должно быть: "Валидация Typescript interface с использованием Joi"


    1. melodyn Автор
      01.05.2019 20:26

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


      1. 183614956
        01.05.2019 20:40

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


        1. melodyn Автор
          01.05.2019 20:58

          Исправил, спасибо за замечание!

          Также, добавил в статью то, о чём общался с комментаторами выше.


  1. 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()])
    }
    


    1. 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()])
      };


      1. reforms
        05.05.2019 10:15

        Почему класс, а не интерфейс и типы?

        Это след экспериментов, хотел подружить нормальное описание данных в классе с декораторами на типы Joi. Что-то слету у меня не получилось. А так конечно уместен интерфейс (тип), а не класс :)