Когда создавалась библиотека для валидации данных 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 типами была сделана нарочно минимальной. На мой взгляд — это позволяет сделать схемы более легкими для чтения и модификации.