Как стать автором
Обновить

Zod умер. Да здравствует ajv-ts

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров12K
Автор оригинала: Vitali Haradkou

TLRD: zod не подходил в проекте и решили сделать свой builder с помощью ajv в zod-like API. Поскольку гугление не показало никаких вменяемых результатов - было решено сделать свои костыли решения.

больше не с zod.
больше не с zod.

Что такое zod?

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" и выдавать ошибку, если аргумент не соответствует схеме.

Почему бы не выбрать zod?


В текущем проекте мы уже используем валидатор схемы ajv. Поскольку у zod есть собственная валидация, которая не поддерживает JSON-Schema и openAPI без плагинов то надо потратить уйму времени для того, чтобы подружить плагины и не факт что это сработает. К тому же часть схем у нас уже была написана, хранилась в обычных js фаилах и валидировалась с помощью ajv. Затягивать еще один валидатор было бы слишком.

Это и есть отправная точка моего приключения под названием ajv-ts.

Попытка 1. Набросаем тип Builder

Создадим класс 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();
}

Попытка 2. Infer и вывод типов

Как 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.

Спасибо за прочтение.

UPD. недавно создал свой ТГ канал - там буду постить разное. Не каждая статья будет публиковаться здесь, поэтому есть смысл подписываться - @haradkou_sdet

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Понравилась ли вам статья?
21.88% Да7
18.75% Нет6
37.5% Ну так себе статья12
21.88% Сомневаюсь ответить7
Проголосовали 32 пользователя. Воздержались 6 пользователей.
Теги:
Хабы:
Всего голосов 7: ↑5 и ↓2+5
Комментарии12

Публикации

Истории

Работа

Ближайшие события

19 августа – 20 октября
RuCode.Финал. Чемпионат по алгоритмическому программированию и ИИ
МоскваНижний НовгородЕкатеринбургСтавропольНовосибрискКалининградПермьВладивостокЧитаКраснорскТомскИжевскПетрозаводскКазаньКурскТюменьВолгоградУфаМурманскБишкекСочиУльяновскСаратовИркутскДолгопрудныйОнлайн
24 – 25 октября
One Day Offer для AQA Engineer и Developers
Онлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
26 октября
ProIT Network Fest
Санкт-Петербург
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань