TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
75: Runtime Validation with Valibot
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.
There are no comments for now.