Zod is dead. Long live ajv-ts / Hebrew

Zod is dead. Long live ajv-ts / Hebrew

TLRD: zod didn’t fit in the project and decided to make our builder using ajv in a zod-like API. Since the ingestion did not show any reprehensible results, it was decided to make our own crutches decision.

no longer with zod.

What is zod?

Zod is a schema-level validation library with support for Typescript types.

The library itself is very popular and used in many places. The most popular and which I personally used is trpc, zodios. Personally, I really like zod’s approach to describing structures, so I also like the basis of the library brazenly copied borrowed some solutions.

A short excursion to the zoo. Let’s imagine that we want to create a “User” object with email and password fields. Both of them are mandatory. In zod we will write something similar

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 will handle the input arguments to the function “parseand throw an error if the argument does not match the scheme.

Why not choose zod?

In the current project, we are already using the schema validator ajv. Since zod has its own validation, which does not support JSON-Schema and openAPI without plugins, it takes a lot of time to make the plugins friendly and it is not certain that it will work. In addition, we have already written part of the schemes, stored them in ordinary js files and validated them with the help of ajv. It would be too much to delay another validator.

This is the starting point of my adventure called ajv-ts.

Attempt 1. Sketch the Builder type

Let’s create a class SchemaBuilder

It has the following signature – peeped how zod does it.

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
}

You can ask me:

  • Why is a class abstract? Because we don’t need to allow instantiation SchemaBuildermaybe this is some kind of “general” scheme, they are not specific (for example type: number – this is already a specific scheme)

  • Why you need to define Output? Transformers functions! – my answer. Transformers are functions that transform input or output results. It works both before and after the method safeParse. And most importantly, it allows you to save the chain of input and output generic type.

Let me show you an example:

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"

And the same for postprocess. The idea is simple. Method parse parses the input type and returns the output type.

An example from NumberSchemaBuilder

class NumberSchemaBuilder extends SchemaBuilder<number, NumberSchema> {
  constructor() {
    super({ type: "number" });
  }
  format(type: "int32" | "double") {
    this.schema.format = type;
    return this
  }
}

The general input parameter defines the number type.

The idea is manipulations with the JSON schemabecause many validators understand the JSON schema is the industry standard (hi ​​zod)

zod has its own parser and it does not take into account JSON-Schema. Hello bigint, function, Map, Set, symbol

Bonus: let’s define a numerical function:

export function number() {
  return new NumberSchema();
}

Attempt 2. Infer and derive types

How does zod understand what type your circuit is. I mean how does it work z.infer?

As you may have discovered that Output that’s exactly the type we need. This means that you only need to call Output which is generic, but how is that possible? NumberSchema has no such parameter as OutputThere is only SchemaBuilder.

There are several ways, but the easiest is to create an empty quality with the type we need. Let’s go to the abstract class again and you will understand everything

abstract class SchemaBuilder<Input, Schema extends AnySchema, Output = Input> {
  _input: Input;
  _output: Output;
  // ...other methods
}

_input and _output properties will always be equal in JS Runtime undefinedthese properties are only needed for Infer type Let’s define it

export type Infer<S extends SchemaBuilder<any, any, any>> = S["_output"];

Now we can test if it works:

const MyNumber = s.number()
type Infered = Infer<typeof MyNumber>; // number
const b: number = 3
type B = Infer<typeof b> // never

All together

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();
}

My conclusions

The main thing has been achieved – we have defined a JSON-schema builder, which is quite close to the zod api in terms of its api! And it’s awesome!

The library has a similar API to Zod (but unfortunately not everything can be converted 1-1). Also, I took some liberties and now I’m trying to make the api even more similar to zod.

If you ask me, “Is it worth it?” Definitely yes! – I will answer. First of all, I’ve had a lot of trouble with typescript, generics, and infer types. Second, compare yourself: which approach is more visual 12 lines in JSON-schema or 4 in 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.

Thirdly, now any project team can easily and simply define schemas, and JSON schemas written by our team are delivered to other teams, the number of errors has also decreased, and the number of happy colleagues has increased 😃

Fourthly, somewhere types can become “lost”. never orunknownhere, unfortunately, you can’t do anything, because you can always cut to any (hide pain)

If it was useful to you, I will star or issue on Github, download with npm, or comment on this article.

Project link: Github, NPM.

Thanks for reading.

Related posts