Friday, January 12, 2024

Migrating from Zod to Valibot: A Comparative Experience

Schema Validation
Migration

 views

Cover photo for Migrating from Zod to Valibot: A Comparative Experience

I've recently migrated the validation part of the contact form of my website (a.k.a. this website) from Zod to Valibot. And I would like to share with you the experience.

What is Valibot?

Valibot is commonly known as the "Zod alternative with a smaller bundle size". Similar to Zod, it is a schema validation library. It is aimed at validating unknown data and rejects it if it doesn’t meet the predefined format. Consider it as TypeScript, but the validation happens in runtime while allowing more complicated “rules” to be set up.

Here are some common use cases:

Why should I use Valibot instead of millions of other similar libraries?

What sets Valibot apart from similar libraries, including but not limited to Zod and Yup, is the modular design of Valibot’s APIs. Traditionally, schema validation libraries make use of method chaining to define validation rules, for example, this is how we create an email address validator with Zod:

import { string } from 'zod';
const emailValidator = string().email();

One problem of this API design is that upon importing the string() function, all string-related validators (IP address, UUID, etc.) will be included in your bundle, no matter whether we are using those features or not. This creates unnecessary bloat and can impact the performance of applications, especially when it is used on the client side, which is more sensitive to bundle size.

In comparison, this is how we can implement the same thing with Valibot:

import { email, string } from 'valibot';
const emailValidator = string([email()]);

Unlike the previous code snippet, the string() function here only includes the bare minimum for checking whether the input data is a string and nothing else. Additional functionalities are enabled via importing other validators, which Valibot refers to as “pipes”. This ensures only the necessary logic is included in our bundle.

My use case

As mentioned, my use case is the contact form of this website. The business logic is as follows:

This is how my schema looked like before migration, implemented using Zod. It is used for both client-side and server-side validation.

Zod schema
import { z } from 'zod';
 
const baseContactFormSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  message: z.string().min(1, 'Message is required'),
});
 
export const contactFormSchema = z.discriminatedUnion('showInGuestbook', [
  baseContactFormSchema.extend({
    email: z
      .string()
      .min(1, 'Email is required')
      .email('Not a valid email'),
    subject: z.string().min(1, 'Subject is required'),
    showInGuestbook: z.literal(false),
  }),
  baseContactFormSchema.extend({
    email: z
      .string()
      .email('Not a valid email')
      .optional()
      .or(z.literal('')),
    subject: z.string().optional(),
    showInGuestbook: z.literal(true),
  }),
]);
 
export type ContactFormData = z.infer<typeof contactFormSchema>;

And this is how my schema currently looks like with Valibot.

Valibot schema
import {
  Output,
  email,
  literal,
  merge,
  minLength,
  object,
  optional,
  string,
  union,
  variant,
} from 'valibot';
 
const baseContactFormSchema = object({
  name: string([minLength(1, 'Name is required')]),
  message: string([minLength(1, 'Message is required')]),
});
 
export const contactFormSchema = variant('showInGuestbook', [
  merge([
    baseContactFormSchema,
    object({
      email: string([
        minLength(1, 'Email is required'),
        email('Not a valid email'),
      ]),
      subject: string([minLength(1, 'Subject is required')]),
      showInGuestbook: literal(false),
    }),
  ]),
  merge([
    baseContactFormSchema,
    object({
      email: optional(
        union([string([email()]), literal('')], 'Not a valid email'),
      ),
      subject: optional(string()),
      showInGuestbook: literal(true),
    }),
  ]),
]);
 
export type ContactFormData = Output<typeof contactFormSchema>;

Allow me to elaborate on the experience of getting from here, to there.

The Good

Bundle size is as small as advertised

In my case, after finishing the migration, the bundle size of the two libraries are as follows.

Bundle size: Zod vs Valibot
- Zod: 12.37 KB Gzipped
+ Valibot: 1.72 KB Gzipped

That's a nearly 90% size reduction!!!

Obviously, my use case is not the most complex in the world, and the size reduction will diminish as more and more different validators are used, but from my experience, we usually only need a subset of validators on the client side, so chances are you will still get the benefit of (significantly) smaller bundle size.

API is almost a one-to-one mapping to Zod

As shown in the above code snippet, the APIs of Valibot look very familiar if you have experience in other popular schema validation libraries. The author, Fabian Hiller, has taken heavy inspiration from various schema validation libraries when designing the APIs.

I find myself constantly in an "OK, I've been using z.someFunc() from Zod, what's the equivalent of that in Valibot?" mindset while migrating, and that works out pretty well.

The author is open-minded and friendly

The most common GitHub issues in the Valibot repo, besides bug reports, are feature requests. Usually, these consist of "Hey Fabian, would you consider adding X feature that is available in Yup/Zod?". And the answer to that question is typically a yes followed by a quick PR. This is also one of the reasons why Valibot gets so feature-rich in such a short period.

This attitude also applies when answering questions from beginners like me. Unlike popular alternatives, Valibot doesn't have the large user base to support the classic "ask a question on Reddit and someone will answer" model right now. Instead, Fabian is being fairly active on GitHub discussion, answering questions, finding out why such a question is being asked, and where the confusion comes from, while seeing those as opportunities to further improve Valibot.

Here is me trying to ask a noob question while figuring out the API, when Fabian jumps in: https://github.com/fabian-hiller/valibot/discussions/362

The Bad (at least at this stage)

The documentation is seriously lacking

A lot of the pages in the API Reference are simply stubs. This creates considerable difficulties while working on Valibot, especially since some of the functions are named and work (slightly) differently.

For instance, I was looking for the equivalent of or() from Zod in Valibot. And I wasn't aware that merge() is in fact or in both libraries. This takes me quite a while since I have to guess what each of the functions (there are a lot) does by its name and comment and take a look at the source code to prove my assumption.

After that, I composed the following code for validating the optional email field. A one-to-one mapping of my original Zod schema. Note that the field also accepts '', because that needs to be the default value of a controlled MUI Joy UI Input component.

{
  email: optional(
    union([
      string([email('Not a valid email')]), 
      literal('')
    ]),
  )
}

The schema's logic works as expected, but a generic error message is returned, instead of the custom 'Not a valid email' message when the email format is incorrect. As it turns out, the way union() works is whenever any of the associated validators fail, union() itself will also throw an error, and it is its error message being returned. Hence, the correct way of doing it should be:

{
  email: optional(
    union(
      [
        string([email()]), 
        literal('')
      ],
      'Not a valid email'
    ),
  )
}

This behavior is currently undocumented and would require some try and error or studying the source code before knowing it.

Conclusion

Valibot has a lot of potential to be the Zod/Yup replacement, but the documentation quality is its biggest blocker. I would imagine enthusiasts would be interested in trying out Valibot while enjoying the smaller bundle size. And I highly recommend you check it out if you fit the description.

On the other hand, Valibot might be too early for bigger, more serious projects to adopt as the lack of documentation is a risk that can't be overlooked.

Moving forward, I would personally suggest the author and the maintainer(s) focus on the documentation quality instead of adding more features or implementing API changes (yes, there are already new and deprecated APIs respectively at the time this article is written). The feature set is comprehensive enough to compete with the incumbents. What the team should do is to make it easier for people to learn how to use them.

Update: 18 Jan, 2024

The reception of this article turns out to be quite positive and I’m pleasantly surprised that Fabian has shared this article on X. Here are some updates to Valibot since then.

  1. Starting from v0.26.0, the expected behavior for union() becomes the previously mentioned "wrong code". i.e.
    {
      email: optional(
        union([
          string([email('Not a valid email')]), 
          literal('')
        ]),
      )
    }
  2. Fabian decided to switch focus to completing the documentation for non-async APIs, target by the end of January.

Update: 18 Apr, 2024

  1. An API redesign on how "pipes" interact with each other are currently being WIP. As such, the documentation effort is currently on hold.
  2. I have decided to switch back to Zod for now, mainly due to T3 Env not supporting Valibot at this stage. Personally, I believe the benfits broght by T3 Env outweigh that of Valibot for my project, but I will surely switch back to Valibot once it is supported.

Written By

Matthew Kwong

Matthew Kwong

Senior Web Engineer @ Viu