Zod умер. Да здравствует ajv-ts
- воскресенье, 28 января 2024 г. в 00:00:13
TLRD: zod не подходил в проекте и решили сделать свой builder с помощью ajv в zod-like API. Поскольку гугление не показало никаких вменяемых результатов - было решено сделать свои костыли решения.
Zod — это библиотека проверки на уровне схемы с поддержкой типов Typescript.
Сама библиотека очень популярна и используется много где. Самые попуярные и какие я лично использовал это trpc, zodios. Лично мне очень симпатизирует подход zod к описанию структур, поэтому за основу библиотеки я также нагло скопировал позаимствовал некоторые решения.
Краткий экскурс в zod. Давайте представим, что мы хотим создать объект «User» с полями электронной почты и пароля. Оба они - обязательны. В zod мы напишем нечто подобное
import z from "zod";
const UserSchema = z.object({
username: z.string(),
password: z.string(),
});
type User = z.infer<typeof UserSchema>; // {username: string, password: string}
const admin = UserSchema.parse({
username: "admin",
password: "admin",
}); // OK
const guestWrong = UserSchema.parse({
username: "admin",
password: 123,
}); // throws Error. Password is not a string
Zod будет обрабатывать входящие аргументы функции "parse" и выдавать ошибку, если аргумент не соответствует схеме.
В текущем проекте мы уже используем валидатор схемы ajv. Поскольку у zod есть собственная валидация, которая не поддерживает JSON-Schema и openAPI без плагинов то надо потратить уйму времени для того, чтобы подружить плагины и не факт что это сработает. К тому же часть схем у нас уже была написана, хранилась в обычных js фаилах и валидировалась с помощью ajv. Затягивать еще один валидатор было бы слишком.
Это и есть отправная точка моего приключения под названием ajv-ts.
Создадим класс SchemaBuilder
У него следующая сигнатура - подглядел как это делает zod.
abstract class SchemaBuilder<Input, Schema extends AnySchema, Output = Input> {
constructor(readonly schema: Schema) {
// schema is a JSON-schema notation
}
safeParse(input: unknown): SafeParseResult {
// logic here
}
parse(input: unknown): Output {
const { success, data, error } = this.safeParse(input);
if (success) {
return data;
}
throw error;
}
// rest methods
}
Вы можете спросить у меня:
Почему класс является абстрактным? Потому что нам не нужно разрешать создание экземпляра SchemaBuilder, тк это некая "общая" схема, те не конкретная(например type: number - это уже конкретная схема)
Почему вам нужно определить Output
? Функции трансформеры! - мой ответ. Трансформеры — функции, преобразующие входные или выходные результаты. Он работает как до, так и после метода safeParse. И самое главное - позволяет сохранить цепочку входного и выходного generic типа.
Позвольте мне показать вам пример:
import s from 'ajv-ts';
const MySchema = s.string().preprocess((x) => {
//If we got the date - transform it into "ISO" format
if (x instanceof Date) {
return x.toISOString();
} if (typeof x === 'number'){
return String(x)
}
return x;
}, s.string()); // input: unknown -> string, output: string
const a = MySchema.parse(new Date()); // returns "2023-09-27T12:25:05.870Z"
const b = MySchema.parse(123); // returns "123"
И то же самое для postprocess. Идея проста. Метод parse анализирует входной тип и возвращает выходной.
Пример с NumberSchemaBuilder
class NumberSchemaBuilder extends SchemaBuilder<number, NumberSchema> {
constructor() {
super({ type: "number" });
}
format(type: "int32" | "double") {
this.schema.format = type;
return this
}
}
Общий входной параметр определяет тип номера.
Идея заключается в манипуляциях с JSON-схемой, потому что многие валидаторы понимают JSON-схему — это стандарт индустрии(привет, zod)
У zod есть собственный парсер, и он не учитывает JSON-Schema. Привет bigint, function, Map, Set, symbol
Бонус: определим числовую функцию:
export function number() {
return new NumberSchema();
}
Как zod понимает, какого типа ваша схема. Я имею в виду, как работает z.infer
?
Как вы, возможно, обнаружили, что Output
— это именно тот тип, который нам нужен. Это означает, что вам нужно только вызвать Output
который является generic, но как это возможно вызвать? NumberSchema
не имеет такого параметра, как Output
, есть только SchemaBuilder.
Есть несколько путей, но самый простой, это создание пустого свойства с нужным нам типом. Давайте снова переключися на над абстрактный класс и вы все поймете
abstract class SchemaBuilder<Input, Schema extends AnySchema, Output = Input> {
_input: Input;
_output: Output;
// ...other methods
}
_input
и _output
свойства всегда в JS Runtime будут равны undefined
, эти свойста нужны только для Infer
типа. Давайте его определим
export type Infer<S extends SchemaBuilder<any, any, any>> = S["_output"];
Теперь мы можем проверить, что это работает:
const MyNumber = s.number()
type Infered = Infer<typeof MyNumber>; // number
const b: number = 3
type B = Infer<typeof b> // never
abstract class SchemaBuilder<Input, Schema extends AnySchema, Output = Input> {
// type helpers only
_input: Input;
_output: Output;
// JSON-schema
schema: Schema
// ajv Instance, used for validation
ajv: Ajv
safeParse(input: unknown): SafeParseResult {
try {
const isValid = this.ajv.validate(input, this.schema)
return {
success: true,
data: input,
}
} catch (e){
return {
error: this.ajv.errors[0],
success: false
}
}
}
parse(input: unknown); // implementation
}
class NumberSchemaBuilder extends SchemaBuilder<number, NumberSchema> {
constructor() {
super({ type: "number" });
}
format(type: "int32" | "double") {
this.schema.format = type;
}
}
export function number() {
return new NumberSchema();
}
Главное достигнуто — мы определили строитель JSON-схемы который по своему апи довольно близок к zod api! И это потрясающе!
Библиотека имеет подобный API, что и Zod (но к сожалению не все можно сконвертировать 1-1). К тому же, я позволил себе некоторые вольности, а теперь пытаюсь сделать api еще более похожим на zod.
Если вы спросите меня: "Стоило ли оно того?" Одназначно да! - отвечу я. Во-первых, я сильно прокачался в typescript, generics и infer types. Во-вторых, сравните сами: какой подход более наглядный 12 строк в JSON-schema или 4 в ajv-ts?
const Schema1 = {
type: 'object',
properties: {
"transferId": {
"type": "string",
"nullable": true
},
"deduplicationId": {
"type": "string",
"nullable": true
}
},
}
const Schema2 = s.object({
transferId: s.string().nullable(),
deduplicationId: s.string().nullable(),
})
Schema2.schema // тоже самое что и Schema1.
В-третьих, теперь любая команда проекта может легко и просто определять схемы, а также JSON-схемы написанные нашей командой поставляются другим командам, кол-во опечаток также уменьшилось, а кол-во радостных коллег увеличилось 😃
В-четвертых, где-то типы могут "потерятся" стать never
илиunknown
, тут к сожалению ничего не поделать, ведь всегда можно скастить к any
(hide pain)
Если вым было полезно, то буду раз звездочке или issue на Github, скачиванию с npm, либо коментариям этой статьи.
Ссылка на проект: Github, npm.
Спасибо за прочтение.