Когда создавалась библиотека для валидации данных quartet
были поставленны следующие цели-ориентиры:
- TypeScript
- Краткость и простота
- Производительность
В этой статье я хотел бы рассмотреть ориентированность 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,
}
})
Кол-во дополнительных символов: 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())
});
Тут сайт не очень хорошо подсветил дополнительные символы, но если точно подсчитать, до будет 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"
}
}
}
});
Они и не подразумевались быть похожими, но разница 146 символов.
Сравним результаты:
Конечно эти библиотеки не стремились создать схемы похожие на TypeScript. Но, на мой взгляд, это не значит, что такая схожесть не является плюсом.
Итого
Наличие встроенной поддержки TypeGuard механизма — одна из мелочей, но приятных.
Схожесть схем с TypeScript типами была сделана нарочно минимальной. На мой взгляд — это позволяет сделать схемы более легкими для чтения и модификации.
dark_ruby
Пользуясь случаем оставлю ссылку на свою библиотеку которая делает очень похожие вещи, но в сугубо фунцкиональном стиле https://github.com/venil7/json-decoder
kila_vat
Да, напоминает Either из Haskell, мне нравятся такие подходы
dark_ruby
ну я вдохновлялся декодером из Elm, а там и до Хаскеля не далеко
kila_vat
Elm гарантирует, что код написанный на нём не упадёт. Вам нужно немного больше тестов написать:
Я вам пулл реквест скинул, гляньте, может быть, что-то вам придётся по душе.
У вас приятно смотреть на код)
dark_ruby
спасибо, напишу в личку
nin-jin
Ну и я со своей торбой.
Отличительная особенность:
Пример из статьи выглядит так:
А использование, соответственно:
kila_vat
Люблю все решения с автоматическим выводом
kila_vat
Интересно узнать с какой целью вы дали именно такие жесткие имена на выходе своей библиотеки.
Причем очевидно вы сами знаете, что они не удобные — потому что в этом же примере их переименовываете.
В этом есть какой-то скрытый смысл?
nin-jin
В МАМ экосистеме этот код выглядел бы так:
Никаких импортов и переименовываний. Но в NPM приняты импорты и короткие имена, поэтому в том примере показан более традиционный путь. Почему переименовывание не вынесено внутрь npm модуля:
babylon
Дмитрий, как задаётся схема в декларативном виде. Если конечно задаётся?
nin-jin
Речь о загрузке схемы из файла? Боюсь, что тут такое не поддеживается. Для этого нужно будет делать DSL на JSON, что выглядит достаточно стрёмно.