Published 11-06-2024
Valibot is a schema library for Typescript similar to libraries like Zod. Valibot is unique in the sense that it is fully modular. Meaning the size of your project grows based on what you actually use from Valibot.
This article will discuss using Valibot to implement design patterns and data pipelines in Typescript. Resulting in code that is readable, resilient, and fully type safe.
To install Valibot: npm i valibot
Valibot provides functions for building schemas. The library is fully modular and tree-shakeable. The example snippet below would add 1.23kB
(gzip) to your bundle size. Using Zod, this same example would add 13kB
.
A difference of
12kb
may not seem significant. However, because Valibot is tree-shakeable, they can continuously add functions. As a result, Valibot can eventually be a much larger library than Zod, but still result in a smaller bundle size.
import * as v from "valibot"; // 📦 1.23 kB
// Define the schema
const Schema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
theme: v.optional(v.picklist(["light", "dark"]), "dark"),
});
// Infer static types
type Input = v.InferInput<typeof Schema>;
type Output = v.InferOutput<typeof Schema>;
// Parse the schema:
// ✅ Valid data returns output object
// 💥 Invalid data throws exception with issues
const result = v.parse(Schema, {
email: "flotes@example.com",
password: "12345678",
});
Schemas provide many benefits and utility for writing resilient code
We’ll continue to explore Valibot’s capabilities through practical examples and design patterns.
Valibot’s parse
method performs run-time validation on data against our schema. If the data is valid, it will return the object updated with any transforms, coercion, or defaults. If the data is invalid, it will throw an exception with all issues found.
This CLI program is configurable by a JSON file. JSON does not enforce type safety. Thus, the user can pass any type of data to any key.
This (minimal) example allows a user to configure rules to a conventional git commit. For example, they can set the title_max_size
to be any number greater than 1
, but not less 1
.
const ConfigSchema = v.strictObject({
title_enabled: v.optional(v.boolean(), true),
title_required: v.optional(v.boolean(), true),
title_max_size: v.optional(v.pipe(v.number(), v.minValue(1)), 70),
});
type ConfigInput = v.InferInput<typeof ConfigSchema>;
type ConfigOutput = v.InferOutput<typeof ConfigSchema>;
/* Log exceptions and exit the program */
function validate_data(data: ConfigInput): ConfigOutput {
try {
return v.parse(ConfigSchema, data);
} catch (err) {
err.issues.forEach((issue) => console.error(issue.message));
process.exit(0);
}
}
This example creates defaults for missing values. While failing fast on invalid data or states.
This shows how flexible Valibot is with it’s short and concise syntax. Defaults to simplify the user experience combined with checks and validations that throw exceptions.
Additionally, our static types reflect this functionality. ConfigInput
defines each property as optional. ConfigOutput
defines each property as its type. For example, a state where title_max_size
is returned as undefined
is essentially impossible. ConfigOutput
is typed in a way that reflects that.
let valid_data: ConfigOutput;
// { "title_max_size": 100 }
const file_output = JSON.parse(fs.readFileSync("config.json"));
// ✅ returns { title_max_size: 100, title_enabled: true, ... }
valid_data = validate_data(ConfigSchema, file_output);
// { "title_max_size": "100" }
const file_output = JSON.parse(fs.readFileSync("config.json"));
// 💥 ValiError: Expected number but received string
valid_data = validate_data(ConfigSchema, file_output);
CLI programs on startup can log and safely exit from an exception. However, applications running in production do not have this luxury. When we need to handle invalid data gracefully, Valibot provides safeParse
. This method returns success
, output
and issues
instead of throwing an exception.
This study timer takes user input through a form element to update user preferences. Additionally, it will retrieve & save those preferences from & to local storage. Like our json file example, local storage does not enforce type safety.
We’ll provide defaults for all values through our schema. When a user updates a field to an invalid value, we will log the issue and fallback to the previous valid value.
const PreferencesSchema = object({
study_time: optional(pipe(number(), minNumber(1)), 25),
break_time: optional(pipe(number(), minNUmber(1)), 5),
spotify_playlist_id: optional(
pipe(string(), length(22, "Playlist ID must be 22 characters")),
"4PD5kOItNHxlcyigP3N58O",
),
});
type PreferenceInput = v.InferInput<typeof PreferencesSchema>;
type PreferencesOutput = v.InferOutput<typeof PreferencesSchema>;
let state: PreferencesOutput;
// Takes data from an html form or local storage
function set_preferences(preferences: PreferenceInput) {
const result = v.safeParse(PreferencesSchema, preferences);
if (result.success) {
state = result.output; // ✅ valid data - update
} else {
// 💥 invalid data - do not update - log issues
result.issues.forEach((issue) => {
console.error(issue.message);
});
}
}
For example, a Spotify playlist ID is always 22
characters. If the user inputs anything other than a 22
character string, log the issue and keep the last valid playlist ID. The program continues to run with valid data.
An advanced example might asynchronously check for a real Spotify ID.
Valibot provides functions to coerce or fallback to different values without returning issues. Imagine the user inputs the string "10"
instead of the number 10
. Rather than log an error or notify the user, Valibot can coerce the value to 10
without creating an issue.
const SafeNumberSchema = v.pipe(
v.union([v.string(), v.number()]),
v.transform((value) => +value || 10), // string --> number
v.minValue(10), // less than 10, set to 10
v.maxValue(20), // greater than 20, set to 20
);
Sometimes falling back to valid values can obfuscate potential bugs. Additionally, validating scenarios that are impossible can create unwanted performance overhead. - Use these techniques judiciously.
Valibot enables fully type-safe data transformation through pipelines. The transform
function receives run-time validated input matching its static type. The output type is inferred based on the value returned.
This example will convert a number to a human readable time in MM:SS
. TimerSchema
defines a property time
that is a number
when input, and a string
when output.
Our schema enforces that time
will always be a positive number when input to the transform
function. Additionally, time
will always be returned as a non-empty string. This makes our pipeline consistent, predictable, and prevents potential bugs.
const TimerSchema = v.object({
time: v.pipe(
v.number(),
v.toMinValue(0),
v.transform((input) => format_time(input)),
),
});
// ✅ Typescript knows time input is a number
const result = parse(TimerSchema, { time: 60 });
// ✅ Typescript knows time output is a string
result.time; // "01:00"
// ✅ Number passed to format_time has been validated
function format_time(time: number) {
return (
Math.floor(time / 60)
.toString()
.padStart(2, "0") +
":" +
(time % 60).toString().padStart(2, "0")
);
}
This example transforms a single property. However, transform
can be passed to any pipe
function. For example, using transform
on an object. This enables manipulating properties based on the value of other properties.
const TimeObjectSchema = pipe(
object({
time: number(),
isPaused: boolean(),
}),
transform((state) => {
// ✅ If time is 0, update isPaused to true
if (state.time === 0 && !state.isPaused) {
return { isPaused: true, ...state };
} else {
return state;
}
}),
);
Of the examples we went over, we didn’t cover what might be one of the most common use cases of Valibot, server requests. Libraries like tRPC or Hono can utilize Valibot. This adds static type inference and run-time validation to our APIs.
If you’re interested in Valibot, I recommend checking out their documentation. I’ve been using Valibot for my personal projects, and my experience has been great. Also, the author is very responsive to questions Github and Discord.
Lastly, I’m building an ecosystem of learning tools. Developed outside my role as a Senior Software Engineer at Cisco. I do 100% of the coding and writing of these projects and articles. If you enjoyed this article, consider checking out my projects or sharing it with others. Thanks for reading!