Quartet 9: Allegro | TypeScript

    Когда создавалась библиотека для валидации данных quartet были поставленны следующие цели-ориентиры:



    В этой статье я хотел бы рассмотреть ориентированность quartet на TypeScript.


    Мотивация


    Мы работаем на своих проектах с использованием TypeScript. Поэтому, когда я создавал эту библиотеку, мне хотелось, чтобы человек, который знает TypeScript, не изучал quartet, как нечто ему совсем новое, а узнавал в этой библиотеке то, что он уже знает.


    User-Defined Type Guards


    Рассмотрим пример. Мы запрашиваем данные про пользователя с API. Предполгаем, что они имеют следующий тип:


    interface User {
      id: string;
      name: string;
      gender: "male" | "female";
      age: number;
      phoneBook: {
        [name: string]: string;
      };
    }

    Что я хочу получить от функции валидации:


    const probablyUser: unkown = { ... }
    
    if (checkUser(probablyUser)) {
        // probablyUser has type User
        console.log(probablyUser.name)
    } else {
        // probablyUser has type unkown
        throw new Error('Probably User has not type User')
    }

    Для достижения такой цели используются User-Defined Type Guards.


    То есть объявление функции должно иметь следующий вид:


    function checkUser(probablyUser: any): probablyUser is User {
      // ...
    }

    Давайте используем quartet для создания такой функции:


    import { v } from "quartet";
    
    const checkUser = v({
      id: v.string,
      name: v.string,
      gender: ["male", "female"],
      age: v.number
      phoneBook: {
        [v.rest]: v.string,
      }
    });

    Написав такой код мы получим на выходе функцию, которая не является TypeGuard'ом:


    chechUser: (value: any) => boolean;

    Чтобы сделать её TypeGuard'ом необходимо декларативно указать какой именно тип будет валидироватся этой функцией. Это делается так:


    const checkUser = v<User>({
      // ...
    });

    В итоге:


    chechUser: (value: any) => value is User

    Есть два момента касательно этого пункта:


    Гарантии


    Сам факт того, что разработчик может указать какой тип валидируется схемой может насторожить, потому что вполне реально написать так:


    const checkNumber = v<number>({ name: v.string });
    // checkNumber: (value: any) => value is number

    Видим несоответствие — схема написана для типа { name: string }, а разработчик указал, что результат должен иметь тип number.


    Данная возможность была допущена намерено. Потому что средства недопущения подобного(например описанные в статье) приводят к тому, что схема валидации всё меньше похожа на описание типа — и становится более "технической" и менее легкой для написания и чтения.


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


    Детали реализации

    Некоторые сложности возникли, когда я описывал тип функции v. Изначально я писал примерно так:


    const v: <T>(schema: Schema) => (value: any) => value is T;

    Но это делает параметр типа обязательным.


    Я не хотел, чтобы эта возможность с TypeGuard была обязательной.


    Поэтому написал так:


    const v: <T = any>(schema: Schema) => (value: any) => value is T;

    Но это привело к тому, что когда валидация не проходила — она присваивала переменной тип never:


    const checkNumber = v(v.number);
    const value: any = "123";
    
    if (!checkNumber(value)) {
      // value has type never
    }

    Ну это логично потому что, если переменная не любого типа, то она никакого типа.


    Мне нужно было иметь тип, который смог бы определить является ли T типом any и поставил бы один тип результата, а если T === any, то другой.


    Я хотел написать как-то так:


    const v: <T = any>(
      schema: Schema
    ) => IfAny<T, (value: any) => boolean, (value: any) => value is T>;

    type IfAny<T,A,B> = // ...

    И тут я немного завис — писал разные варианты, но сработала в конечном итоге такая идея:


    Если множество типов являются подтипом типа T — то скорее всего он является типом any


    В итоге я написал вот это:


    type IfAny<T, A, B> = true extends T
      ? "1" extends T
        ? 1 extends T
          ? {} extends T
            ? (() => void) extends T
              ? null extends T
                ? A
                : B
              : B
            : B
          : B
        : B
      : B;

    Я предположил, что никто в здравом уме не будет писать код, в результате которого в переменной может быть тип: boolean | number | string | object | function | null и чтобы он не подразумевался быть еквивалентом any.


    Схожесть Схемы и TypeScript типов


    Я хотел добиться того, чтобы написать схему, имея тип TypeScript'a занимало как можно меньше времени.


    Давайте рассмотрим создание схем с использованием @hapi/joi и ajv, для нашего типа User.


    Будем пользоваться сайтом Text Compare чтобы определить схожесть.


    quartet


    const checkUser = v({
      id: v.string,
      name: v.string,
      gender: ["male", "female"],
      age: v.number
      phoneBook: {
        [v.rest]: v.string,
      }
    })

    image


    Кол-во дополнительных символов: 24


    hapi/joi


    const schema = j.object({
      id: j.string().required(),
      name: j.string().required(),
      gender: j
        .string()
        .valid("male", "female")
        .required(),
      age: j.number().required(),
      phoneBook: j.object().pattern(/.*/, j.string())
    });

    image


    Тут сайт не очень хорошо подсветил дополнительные символы, но если точно подсчитать, до будет 118 дополнительных символов.


    ajv


    const checkUser = a.compile({
      type: "object",
      required: ["id", "name", "gender", "age", "phoneBook"],
      properties: {
        id: { type: "string" },
        name: { type: "string" },
        gender: { type: "string", enum: ["male", "female"] },
        phoneBook: {
          type: "object",
          additionalProperties: {
            type: "string"
          }
        }
      }
    });

    image


    Они и не подразумевались быть похожими, но разница 146 символов.


    Сравним результаты:


    image


    Конечно эти библиотеки не стремились создать схемы похожие на TypeScript. Но, на мой взгляд, это не значит, что такая схожесть не является плюсом.


    Итого


    Наличие встроенной поддержки TypeGuard механизма — одна из мелочей, но приятных.


    Схожесть схем с TypeScript типами была сделана нарочно минимальной. На мой взгляд — это позволяет сделать схемы более легкими для чтения и модификации.

    Only registered users can participate in poll. Log in, please.

    Схожесть схем с TypeScript типами — это плюс?

    • 50.0%Да7
    • 14.3%Скорее, да2
    • 28.6%Безразлично4
    • 0.0%Скорее, нет0
    • 0.0%Нет0
    • 7.1%Зависит1
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 44

      0

      Пользуясь случаем оставлю ссылку на свою библиотеку которая делает очень похожие вещи, но в сугубо фунцкиональном стиле https://github.com/venil7/json-decoder

        0

        Да, напоминает Either из Haskell, мне нравятся такие подходы

          0

          ну я вдохновлялся декодером из Elm, а там и до Хаскеля не далеко

            +1

            Elm гарантирует, что код написанный на нём не упадёт. Вам нужно немного больше тестов написать:


            const { objectDecoder, stringDecoder, numberDecoder } = require("json-decoder")
            
            const petDecoder = objectDecoder({
              name: stringDecoder,
              age: numberDecoder,
            });
            
            const result = petDecoder.decode(null)
            // TypeError: Cannot read property 'name' of null

            Я вам пулл реквест скинул, гляньте, может быть, что-то вам придётся по душе.


            У вас приятно смотреть на код)

              0

              спасибо, напишу в личку

          0

          Ну и я со своей торбой.


          Отличительная особенность:


          • выведение типов из схемы
          • позволяет не только валидировать, но и нормализовывать ответ

          Пример из статьи выглядит так:


          import {
              $mol_data_record as Rec,
              $mol_data_string as Str,
              $mol_data_enum as Enum,
              $mol_data_integer as Int,
              $mol_data_dictionary as Dict,
              $mol_data_pattern as Pattern,
          } from 'mol_data_all'
          
          enum Sex {
              'male' = 'male',
              'female' = 'female',
          }
          
          const Phone = Pattern( /^\d{7,15}$/ )
          
          const User = Rec({
              id: Str,
              name: Str,
              gender: Enum( Sex , 'Sex' ),
              age: Int,
              phoneBook: Dict( Str , Phone ),
          })

          А использование, соответственно:


          function printUser( user : typeof User.Value ) {
              console.log( user )
          }
          
          const user = User( json )
          printUser( user )
            0

            Люблю все решения с автоматическим выводом

              +3

              Интересно узнать с какой целью вы дали именно такие жесткие имена на выходе своей библиотеки.


              Причем очевидно вы сами знаете, что они не удобные — потому что в этом же примере их переименовываете.


              В этом есть какой-то скрытый смысл?

                0

                В МАМ экосистеме этот код выглядел бы так:


                enum $my_sex {
                    'male' = 'male',
                    'female' = 'female',
                }
                
                const $my_phone = $mol_data_pattern( /^\d{7,15}$/ )
                
                const User = $mol_data_record({
                    id: $mol_data_string,
                    name: $mol_data_string,
                    gender: $mol_data_enum( Sex , 'Sex' ),
                    age: $mol_data_integer,
                    phoneBook: $mol_data_dictionary( $mol_data_string , $my_phone ),
                })

                Никаких импортов и переименовываний. Но в NPM приняты импорты и короткие имена, поэтому в том примере показан более традиционный путь. Почему переименовывание не вынесено внутрь npm модуля:


                1. npm модуль генерится автоматически, а короткие имена надо задавать вручную. Ну я не заморачивался.
                2. Там лежит не только модули из $mol_data, но и, например, из $mol_fail, $mol_diff и $mol_error. Когда начнём переезд в другой неймспейс, там появятся модули и не из $mol.
            • UFO just landed and posted this here
                0

                Речь о загрузке схемы из файла? Боюсь, что тут такое не поддеживается. Для этого нужно будет делать DSL на JSON, что выглядит достаточно стрёмно.

            +1
            В идеале дополнительного синтаксиса быть не должно вообще.

            interface User {
              id: string;
              name: string;
              gender: "male" | "female";
              age: number;
              phoneBook: {
                [name: string]: string;
              };
            }


            В идеале, этого должно быть достаточно. При помощи рефлект-метадаты получаем эту структуру и на её базе — валидируем JSON
              0
              Тогда, для начала, в TypeScript должны появиться интерфейсы в рантайме
                0

                Но мы вполне можем использовать классы для этого. Довольно допустимо, если будет class User, а не interface User.

                  0

                  Ну class-validator вроде на классах

                    0

                    Хотя на мой взгляд всё же многословно, и не так быстро работает

                    0

                    Да, мы тоже используем для де-/сериализации и валидации декораторы и обычные классы:


                    import { Json, Rule } from 'class-json';
                    
                    class Address {
                        @Rule.validate(FooCustomValidator)
                        @Json.type(String)
                        line: string
                    }
                    class User {
                        @Rule.minimum(1)
                        @Json.type(Number)
                        id: number
                    
                        @Rule.required()
                        @Json.type(Date)
                        date: Date
                    
                        @Rule.pattern(/^\w+$/)
                        @Rule.required()
                        @Json.type(String)
                        name: string
                    
                        @Rule.type(Address)
                        address: Address
                    }
                      0

                      Классная штука(имею в виду использование декораторов + классы), и подобная идея используется во многих языках (c#, Go).


                      Но мне пока не сильно по душе использовать в своей библиотеке не стандартизированные декораторы.


                      const addressSchema = {
                          line: v.and(v.string, v.custom(addressLineValidator)),
                      }
                      const checkUser = v<User>({
                        id: [undefined, v.and(v.number, v.min(1))],
                        date: v.custom(x => x instanceof Date),
                        name: v.and(v.string, v.test(/^\w+$/)),
                        address: [undefined, addressSchema],
                      })
                        0

                        Да, создано с оглядкой на Json.Net. По декораторам: мы не боимся — рано или поздно они войдут в стандарт. И даже если спека немного подправится, то тут тоже всё в норме — на коленке за пару минут можно будет миграционную утилиту написать. Из этого следует — декораторами можно уже наслаждаться. Тут вся прелесть в том, что модель и мета данные к ней — в одном месте, плюс работает наследование.


                        А то что вы в примере написали, это только валидация, или ещё десериализация? То-есть, например, временные строки будут преобразованы в Date?

                  0

                  Подумал насчёт этого.


                  Вполне реально не добавляя рефлексию в тайпскрипт — внести туда подобную штуку:


                  Например можно внести в стандарт языка что-то наподобие такого оператора:


                  if (value is valid User) {
                   // value with type User
                  }

                  Что под капотом создаст функцию


                  function __validUser(value): value is User {
                    if (value == null) return false
                    if (typeof value.name !== 'string') return false
                    if (typeof value.gender !== 'male')
                    // ...

                  И в результирующем коде будет


                  if (__isValidUser(value)) {
                    // ...
                  }

                  Но такой оператор — не введут никогда — потому что это нарушит то, что TypeScript — в большинстве случаев — это валидный js, если убрать описание типов.

                  0
                  Я в исследовательских целях делал генератор на базе AST для конвертирования JSON в инстансы классов и наоборот. Рантайм оверхед — 0. Работу вёл в рамках proof of concept, до рабочей версии довести это пока нет времени. Пользоваться можно было бы как-то так (но с дополнительным степом вызова CLI, чтобы среда разработки, линтеры и люди тоже подхватили новые функции):

                  // src/models.ts
                  import { Transform, Expose } from 'mycooljsonlib@localhost';
                  
                  // Декораторы удаляются из скомпилированного кода, если использовать transformer, 
                  // поставляемый с либой. Подключение, правда, костылями, через ttypescript,
                  // потому что MS не желают (пока?) адекватно поддерживать 
                  // плагины в дефолтном конфиге TypeScript
                  // Декораторы сами по себе - просто маркеры для трансформера, они не меняют
                  // поведение классов и полей
                  @Transform()
                  export class PhoneNumber {
                    name: string;
                    countryCode: string;
                    phone: string;
                  }
                  
                  @Transform()
                  export class User {
                    id: string;
                    name: string;
                    gender: "male" | "female";
                    // Опциональные поля тоже допустимы
                    age?: number;
                  
                    // Возможность переименовывать поля
                    @Expose('phone_book')
                    // В оригинале был объект, но я до этого не дополз, массив работал
                    phoneBook: Array<PhoneNumber>;
                  }
                  
                  // index.ts
                  
                  // Я делал предположение, что лучше разрешить конвертировать любой объект,
                  // чем изначально завязываться на JSON строку
                  const plainObject = JSON.parse('<...input...>');
                  
                  import { plainToUser } from './generated/models';
                  
                  // В дальнейшем можно было бы добавить функции jsonToUser()
                  // На выходе получаем инстанс класса юзер
                  const user = plainToUser(plainObject);
                  
                  // При обратной конвертации - объект
                  const plainAgain = userToPlain(user);
                  
                  // generated/models.ts
                  
                  export function plainToUser(plain): User {
                    const obj = new User();
                  
                    if (typeof plain['id'] === 'string') {
                      obj.id = plain['id'];
                    } else {
                      throw new Error('Invalid type for id: expected string, got <...>');
                    }
                  
                    if (isArray(plain['phone_number'])) {
                      obj.phoneNumber = plain['phone_number'].map(plainToPhoneNumber);
                    } else {
                      throw new Error('...');
                    }
                  
                    // тут остальные проверки для оставшихся свойств
                  
                    return obj;
                  }
                  
                    0

                    Код plainToUser похож, на то, что производит quartet.


                    Мне не сильно по душе классы — я больше сторонник clojure подхода к данным. Но это всё вкусовщина.


                    Но идея кодогенерации вместо рантайм кодогенерации — очень хорошая.


                    А я вот подумываю сделать что-то наподобие .toMatchInlineSnapshot() в jest.


                    Чтобы можно было написать вот так:


                    interface User { ... }
                    
                    const checkUser = v<User>(/* generate */)

                    и утилита бы заменила / generate / на нужную схему для интерфейса User


                    Таким образом рантайм генерация функций осталась бы, но тратить силы на создание схемы не пришлось бы.


                    Есть ещё кейсы, когда валидация должна быть динамической — то есть схема определяется в рантайме — в таком случае — кодогенерация должна быть в рантайм, или в ручную написанная.

                      0
                      Мне не сильно по душе классы — я больше сторонник clojure подхода к данным. Но это всё вкусовщина.

                      Я сейчас много пишу на Реакте и тоже привык к функциональному подходу и иммутабельными данными и трансформациями (как я раньше без этого жил?). У классов в моём случае преимущество состоит в том, что можно применять декораторы к полям. Я планировал добавить поддержку кастомных трансформеров.

                      В выдуманном примере может быть так:
                      @Transform()
                      class DataObject {
                        @Expose({ 
                          toClassField: (value) => value.split(';'), 
                          fromClassField: (value) => value.join(';') 
                        })
                        dataField: Array<number>;
                      }
                      
                      const input = JSON.parse('{ "dataField": "1;2;3;4" }');
                      
                      const obj = plainToDataObject(input);
                      
                      // isArray(obj.dataField) === true
                      
                        0

                        Надеюсь декораторы стандартизируют и точно можно будет делать много чего отрадного, не опасаясь, что их потом поменяют — и решение с декораторами — придёт в негодность.

                  0

                  Сам писал нечто похожее с классами и декораторами, как и большинство комментаторов. Но ушел от этого.


                  Оказывается, апи и фронт, это разные контексты (удивительно). И вам не стоит заворачивать за общие модели. Несмотря на то, что мы используем сваггер, на границе фронт/апи я сейчас стараюсь использовать мапперы, написанные руками (что-то вроде карт контекстов из ДДД). Таким образом вы ограждаетете себя от изменений моделей апи. И наоборот, даёте себе свободу в изменениях. И споров между фронтом и бэком меньше.

                    +1

                    Мне сложно понять как можно быть огражденным от изменений моделей АПИ — если АПИ меняется, ваш маппер должен поменятся, разве нет?


                    Понятно что в таком случае весь остальной код вашего фронта не изменится — но маппер же должен поменятся.

                      0

                      Да, конечно, маппер меняется. Но я под маппером подразумеваю функцию (две функции), которая тип А преобразует в тип Б. Но это вполне себе ограждение.

                        0

                        А факт того, что АПИ вам присылает тип А вы считаете аксиомой, или как-то проверяете?


                        Просто если аксиомой — то вам не нужна валидация в принципе.


                        А если не аксиомой — то какая-то проверка прежде чем использовать эти две функции должна быть.

                          0
                          тип А — это что-то вроде Partial of ApiModel (в идеале unknown, но решаете по мере необходимости). И внутри маппера вы проводите преобразования, дефолтные значения подставляете (можете и ошибки бросать, если надо).
                            0
                            Вот схема из одного проекта, как у меня данные преобразуются и где ошибки ловятся:

                            DirtyApiResponse -> CandyParams -> CandyFactory -> Candy.

                            1) DirtyApiResponse -> CandyParams — это преобразование делает маппер (о котором я выше говорю): могут меняться названия полей, вложенность, простые преобразования строки в дату и т.п. Маппер гарантирует, что на выходе получится CandyParams.

                            2) CandyParams -> CandyFactory — хоть параметры и соответствуют интерфейсу, но внутри могут оказаться логические несостыковки. Эту валидацию более высокого уровня проводит CandyFactory.

                            Это не эталонная схема, просто пример. Но как видно, библиотеки типа quartet, наивно полагают, что они могут сразу преобразовать DirtyApiResponse в Candy, проведя все валидации. В результате, вы напрямую зависите от DirtyApiResponse, и подстраиваете свою модель под него. И наоборот, использование библиотеки для преобразования DirtyApiResponse в CandyParams часто является оверхедом. Вот таким образом я и пришёл к тому, что маплю всё руками. Немного рутины, но зато видно все соответствия/несоответствия на границе фронт/апи.
                              0

                              Вы немного не поняли — quartet не превращает данные. Он гарантирует, что они такой формы, какую вы ожидаете, и к которым можете применить Mapper, CandyParams или CandyFactory.

                                0
                                Тогда перефразирую)

                                некоторые разработчики, наивно полагают, что они могут сразу `function isCandy(value: DirtyApiResponse): value is Candy` и погнали
                                  0

                                  У меня к этому такой подход: пока то, что приходит с API меня устраивает и может использоваться в неизменном виде — я пишу такую функцию(либо руками либо quartet)


                                  Если же нет — я пишу валидацию того, что мне обязательно нужно чтобы приходило с АПИ(это те случаи где вы бросаете ошибку)
                                  И потом по проверенным данным запускаю алгоритм превращения этих данных в мою модель. (без бросаний ошибок)


                                  Но процесс первоначальной проверки — присутствует всегда.


                                  Если АПИ поменялось — есть два варианта, и выбирать любой можно исходя из плюсов и недостатков:
                                  1) Поменять всю свою модель — иногда, когда логика сильно меняется — это может быть оправдано
                                  2) Поменять функцию валидации, и написать маппер из новой АПИ в старую

                                  0
                                  Мне библиотека понравилась. Я воспользуюсь, если будет нужно. Это не критика. Это уточнения, мысли вслух. Воспоминания про свои такие библиотеки.
                                    0

                                    Я тоже не критикую ваш подход. Хотелось понять вашу точку зрения — поэтому и уточняю.

                                  0

                                  Совершенно правильно, что на границе с АПИ должен быть слой дающий на выходе — гарантированные структуру данных и её содержание.

                                  0

                                  Мне не понятно от чего вы ушли — если всё равно делаете проверки и бросаете ошибки, если надо.
                                  Или вы имели в виду, что ушли от подходов с классами и декораторами и заменили их подходами с использованием череды if конструкций, где в одном случае — вы смиряетесь с тем, что данных нет, и заменяете их на валидные дефолтные значения, а в других случаях бросаете ошибку — когда данные не валидны?

                                    0
                                    ну… примерно так. Я перестал возлагать много надежд на автоматизацию этих преобразований.
                              0

                              Всё остальное приложение благодаря мапперу ограждается от изменения апи. Сам маппер, конечно, меняется вслед за изменениями апи. Не редко поддерживая сразу несколько версий апи.

                                0

                                Понятно, что такой слой должен быть, иначе беда и неконтроллируемые состояния

                              0

                              Оказиваеться, можно делить приложение на слои, которые имеют свои модели: UserDto, User и т.д. И "мапперы" тут тоже никто не отменял.

                              0
                              А за что вы невзлюбили подсветку синтаксиса?
                              const probablyUser: unkown = { ... }
                              if (checkUser(probablyUser)) {
                                  // probablyUser has type User
                                  console.log(probablyUser.name)
                              } else {
                                  // probablyUser has type unkown
                                  throw new Error('Probably User has not type User')
                              }

                              Код выше выглядит намного более читаемее, чем код ниже
                              const probablyUser: unkown = { ... }
                              if (checkUser(probablyUser)) {
                                  // probablyUser has type User
                                  console.log(probablyUser.name)
                              } else {
                                  // probablyUser has type unkown
                                  throw new Error('Probably User has not type User')
                              }
                                +1

                                Воспользовался синтаксисом из гитхаба, с тройной обратной кавычкой и названием языка.


                                Но не прокатило, надо поправить

                              Only users with full accounts can post comments. Log in, please.