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

ссылка на оригинал статьи https://habr.com/ru/post/495332/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *