OOPs! No Tree Shaking!

It’s good practice to validate data coming into your program, whether that be from a user, the network, disk, or anywhere else. It’s a first line of defense to prevent bad data from getting into your program and causing problems later, and provides early signals of mismatched expectations or changes in the data format over time.

From JavaScript, JSON is one of the more convenient data serialization formats. Consequently, you need a way to validate that data as well. There are many ways to do this, both declarative and imperative. Declarative, mostly in the form of JSON Schema, holds a lot of appeal to me, but it has some drawbacks of its own.

For my use case, I care a lot about code size. As much as possible, if I am using a library and it has functionality I am not using, I would like that code stripped out of my program by the bundler.

The most prominent JSON Schema validator I could find was AJV. Here’s the problem… AJV is big. Bundlephobia reports AJV 8.17.1 to be 111 KB, before gzipping. That’s about 10x my tolerance level for the functionality I need. Bundlephobia reports the total bundled size, assuming all exported symbols are used, so this could be an overestimate. But there is a fatal flaw: the bundler is not going to inspect your schema to see what features your schema is going to use and trim out the functionality in AJV. Functionally all of AJV is going to be included, used or not. Coffin, meet doornail.

Approaches that construct the schema in code instead of being a data structure are likely to fare better. The most popular in this category that I can find is Joi. Problem is… it’s even bigger than AJV, with Joi 17.3.3 coming out to 145.7 KB. In theory, some of this could be stripped out, but Bundlephobia also says Joi is not compatible with tree-shaking. So you’ll be carrying that 150-or-so KB the whole way through. No good. Also, it’s not as nice with TypeScript, at least according to the judgment of our next pick.

Zod was next on my list. Bundlephobia reports version 3.23.8 as 61 KB, and it’s tree-shakable. Way better! But… it’s not all roses. Say we have some code like this, using the recommendations from the Zod documentation:

import { z } from "zod";

const schema = z.array(z.object({ a: z.string(), b: z.number().int() }));

(async () => {
  console.log(schema.parse(await (await fetch("example.json")).json()));
})();

This bundles down to 55.6 KB. OK, reasonable! But importing z as an object prevents a lot of tree shaking from happening. Zod is perfectly fine with this alternative import syntax, which with this one change shaves off 2 KB, bringing us down to 53.6 KB. Why this isn’t recommended in the documentation, I don’t know:

import * as z from "zod";

But we’re still at 53.6 KB! In fact, even if we have the simplest schema of just z.null(), we only save 0.1 KB, and are stuck at 53.5 KB. The tree shaking isn’t working like it should.

Here’s the problem: z.null is creating a ZodType, and a ZodType has a huge number of utility methods on it. Methods can’t be tree-shaken out, so all of those are coming for the ride. That includes methods like .array(), which pulls in ZodArray, and similar other inclusions that, in total, mean you basically pull in all of Zod whenever you use any of it. parse, safeParse, parseAsync etc all being methods means you can never dispose of any of those either. This sucks.

Though this may hurt readability, to focus on bundle size, you want to have lots of independent free functions, unless they really have to be methods, and find ways of reducing internal coupling in other places too, if you can.

To cap off this post, I also found Valibot, which claims prominently to focus on bundle size. Does it live up to its claims? Bundlephobia reports that version 0.36.0 is 65.5 KB, but as we’ve seen, this assumes we’re using the whole library, and for our purposes, we’re not. Translating our example to Valibot, how do we fare?

import * as v from "valibot";

const schema = v.array(
  v.object({
    a: v.string(),
    b: v.pipe(v.number(), v.integer()),
  }),
);

(async () => {
  console.log(v.parse(schema, await (await fetch("example.json")).json()));
})();

This bundles down to 3.3 KB (2.4 KB with excessive use of esbuild’s --mangle-props). So yes, Valibot does live up to its claims! Careful use of free functions instead of OOP help Valibot be effectively tree-shakable. Looking through the bundled result, there does not appear to be much fat – the only thing even slightly suspect is the error message generation, but even that is quite terse and provides broadly-useful benefits.

Tags: , , ,

Leave a Reply