Skip to Content
Course content

75: Runtime Validation with Valibot

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Here is the reality of TypeScript: it's a liar. Or rather, it's an optimist. When you type a function argument as User, TypeScript believes you. But at runtime, when that data actually hits your app from a REST API or a local storage key, it's just a plain JavaScript object. If that object is missing a field or has a string where a number should be, your app crashes, and your beautiful types didn't save you.

That's where Valibot comes in. I prefer it over Zod these days because it's modular. Instead of importing one massive bundle, you import only the validators you actually use, which keeps your bundle size lean. Let's build a validator for a "Notification Request" system—something that handles different types of alerts (Email, SMS, and Push).

Defining the Notification Schema

I want to ensure that every notification request has a priority level and a recipient. I'll start by defining a basic schema. Notice how I'm using v.object and v.string; these aren't types, they are runtime functions that check the data.

import * as v from 'valibot';

const NotificationSchema = v.object({
  priority: v.picklist(['low', 'medium', 'high']),
  recipient: v.string(),
  message: v.string([v.minLength(1, 'Message cannot be empty')]),
});

I added a custom error message to the minLength check. It's a small touch, but it makes your API responses much more helpful for whoever is consuming your service.

The "Any-Channel" mistake

Now, I need to handle the delivery channel. I'll start by just adding a channel field and an optional contactInfo field. Let's see how that looks:

const NotificationSchema = v.object({
  priority: v.picklist(['low', 'medium', 'high']),
  recipient: v.string(),
  message: v.string(),
  channel: v.picklist(['email', 'sms', 'push']),
  contactInfo: v.string(), // I'll make this optional in my head
});

Wait. I just realized I've created a bug. If the channel is 'email', I must have an email address. If it's 'sms', I need a phone number. By making contactInfo a generic string, I'm allowing a "push" notification to be sent to a phone number, or an "email" notification to be sent to a device token. That's a recipe for runtime failures downstream.

I need a Discriminated Union. I'll refactor the schema to use v.union, so the requirements change based on the value of the channel.

const NotificationSchema = v.union([
  v.object({
    channel: v.literal('email'),
    emailAddress: v.pipe(v.string(), v.email('Invalid email format')),
    priority: v.picklist(['low', 'medium', 'high']),
    message: v.string(),
  }),
  v.object({
    channel: v.literal('sms'),
    phoneNumber: v.string(),
    priority: v.picklist(['low', 'medium', 'high']),
    message: v.string(),
  }),
]);

Now Valibot knows that if channel is 'email', it must find an emailAddress field that actually looks like an email. Much safer.

Handling the validation result

I could use v.parse(), but that throws an error if validation fails. In a real production app, I hate wrapping every single API call in a try/catch block. It gets messy. Instead, I'll use v.safeParse(). It returns an object telling me whether the validation succeeded or failed.

function handleNotification(data: unknown) {
  const result = v.safeParse(NotificationSchema, data);

  if (!result.success) {
    console.error('Validation failed:', result.issues);
    return { error: 'Invalid notification data' };
  }

  // At this point, result.output is fully typed!
  console.log(`Sending ${result.output.channel} to ${result.output.recipient}...`);
}

Connecting Valibot to our TypeScript types

The last thing I don't want to do is maintain a separate TypeScript interface and a Valibot schema. That's double the work and a guaranteed way to let them get out of sync. I'll use v.InferOutput to let the schema drive the type.

type Notification = v.InferOutput<typeof NotificationSchema>

function sendToProvider(payload: Notification) {
  // TypeScript now knows exactly what's in here
  // based on the Valibot schema we already wrote.
}

This is the "golden path." Your schema is the single source of truth for both the runtime check and the compile-time type.




📋 Practical Task

Build a User Profile Validator

You are building a user profile update feature. Create a Valibot schema for a UserProfile that enforces the following rules:

  • Username: A string that must be between 3 and 20 characters.
  • Age: An optional number that must be at least 18.
  • Role: A picklist consisting of 'admin', 'editor', or 'viewer'.
  • Settings: A nested object containing a boolean notificationsEnabled.

Once the schema is defined, write a function called validateProfile that takes an unknown input, uses safeParse to validate it, and returns the validated data or a descriptive error message. Finally, use v.InferOutput to create a TypeScript type based on this schema.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.