Evaluating Schema Validation Libraries
- Published On
- Read Time
- 4 min
On This Page5 sections
Recently1, I evaluated schema validation libraries for our React app as part of our migration to TanStack Router. The schemas for our routes are relatively simple, so I tried implementing validation for a form instead.
Form
My hypothetical form consists of three fields:
╭─────────────────────────────╮
│ Name * │
╰─────────────────────────────╯
╭─────────────────────────────╮
│ Description │
╰─────────────────────────────╯
╭─────────────────────────────╮
│ Item * ▾ │
╰─────────────────────────────╯
┏━━━━━━━━━━┓
┃ Save ┃
┗━━━━━━━━━━┛
The Name field is a required text input with a min length of 1. The Description field is an optional text input that will be initialized with an empty string. For the output, when saving the form, an empty string should be converted to null. And lastly, Item is a dropdown field that stores the UUID of the selected item. The initial value is null, but as the field is required, the corresponding output type should be Guid and not include null.
So to summarize, we have the following properties:
| Property | Required | Input Type | Output Type |
|---|---|---|---|
name | ✅ | string | string |
description | string | string | null | |
itemId | ✅ | Guid | null | Guid |
Now let’s try to implement a schema for these form values using Zod, Valibot, and ArkType.
Zod
I used Zod Mini, which is the tree-shakable version of Zod. Its API is slightly less ergonomic, but in a browser environment, it’s good to have a smaller bundle size.
For the name, we use z.string() and add a check with z.minLength(1):
const name = z.string().check(z.minLength(1));
The description field is also a z.string(), but we add a transform function to convert empty strings to null. The transform function creates an output type of string | null while keeping the input type as string only.
const description = z.pipe(
z.string(),
z.transform((val) => val || null),
);
type Input = z.input<typeof description>; // string
type Output = z.output<typeof description>; // string | null
For the Item field, we first need a schema for the UUID. The schema output type should match the following TypeScript type:
type Guid = `${string}-${string}-${string}-${string}`;
Zod supports z.uuid() but it outputs a string. We can combine it with z.custom() to improve type-safety:
const guid = z.intersection(z.uuid(), z.custom<Guid>());
Next, we want to allow null values because the field will be initialized with null. We can wrap the guid schema with z.nullable() to achieve this. Then, to make the output type non-nullable, we add a custom transform function:
const itemId = z.pipe(
z.nullable(guid),
z.transform((val, ctx) => {
if (val === null) {
ctx.issues.push({
code: "custom",
message: "Required",
input: val,
});
return z.NEVER;
}
return val;
}),
);
type Input = z.input<typeof itemId>; // Guid | null
type Output = z.output<typeof itemId>; // Guid
And now, combining everything together with z.object():
const guid = z.intersection(z.uuid(), z.custom<Guid>());
const formSchema = z.object({
name: z.string().check(z.minLength(1)),
description: z.pipe(
z.string(),
z.transform((val) => val || null),
),
itemId: z.pipe(
z.nullable(guid),
z.transform((val, ctx) => {
if (val === null) {
ctx.issues.push({
code: "custom",
message: "Required",
input: val,
});
return z.NEVER;
}
return val;
}),
),
});
Valibot
Valibot is very similar to Zod Mini, so let’s just take a look at the final code:
const guid = v.intersect([
v.pipe(v.string(), v.uuid()),
v.custom<Guid>(() => true),
]);
const formSchema = v.object({
name: v.pipe(v.pipe(v.string(), v.minLength(1))),
description: v.pipe(
v.string(),
v.transform((val) => val || null)
),
itemId: v.pipe(
v.nullable(guid),
v.rawTransform(({ dataset, NEVER, addIssue }) => {
if (dataset.value === null) {
addIssue({ message: "value cannot be null" });
return NEVER;
}
return dataset.value;
})
);
});
Valibot’s custom() function requires a validation function, but we always return true because the value is already validated by v.uuid(). The transform function has a slightly different syntax but works in basically the same way.
ArkType
ArkType has a very different syntax that’s more oriented toward how you write TypeScript types; it’s shorter and often more readable.
The name schema is a string with a min length of one:
const name = type("string >= 1");
The description schema is also a string but the output is a nullable string. Here we can use pipe() to apply a custom transform (morph) function:
const description = type("string").pipe((val) => val || null);
type Input = typeof description.inferIn; // string
type Output = typeof description.inferOut; // string | null
For the Item field, we again need a UUID schema. ArkType supports UUIDs with string.uuid.v4, and then we can cast it to our Guid type using as():
const guid = type("string.uuid.v4").as<Guid>();
We then append .or("null") to allow nullable input and apply a morph function for non-nullable output. The morph function either returns a value or an error message:
const itemId = guid.or("null").pipe((val, ctx) => val ?? ctx.error({ message: "Required" }));
Finally, we can merge everything together:
const guid = type("string.uuid.v4").as<Guid>();
const formSchema = type({
name: "string >= 1",
description: type("string").pipe((val) => val || null),
itemId: guid.or("null").pipe((val, ctx) => val ?? ctx.error({ message: "Required" })),
});
Looks pretty nice.
And the winner is…
We went with Zod. Thanks to Zod Mini, the library is now tree-shakable, which previously was the main advantage of Valibot. ArkType seems pretty cool, but for more complex scenarios, Zod seems easier to use.
Footnotes
-
Okay okay, you caught me - not so recently, this post has been in draft status for quite some time. ↩