Bulletproof Typescript with Valibot

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.

Getting Started

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

  • Run-time validation: Assert values match constraints & static types.
  • Static type inference: Infers static types from the schema definition.
  • Populate defaults: Populate missing fields with defaults.
  • Transform: Modify and coerce resulting output.

We’ll continue to explore Valibot’s capabilities through practical examples and design patterns.

Offensive Programming (Fail Fast)

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.

Example · Configurable CLI Program

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.

  • An empty json file would produce a valid object with all default values
  • A json file with invalid values or keys that do not exist in the schema would throw an exception

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

Defensive Programming (Fail Safe)

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.

Example · Web Application

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.

Fallback without Issue

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
);
Overly Defensive Programming

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.

Pipeline Composition

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.

Example · Timer State

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

Conclusion

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% 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!

  • Flotes: Markdown note-taking built for learning.
  • Tomatillo Timer: Highly configurable study timer that syncs to music.
  • Discord Channel: Join other tech enthusiasts, students, and engineers.
Wed Nov 06 2024