The most surprising thing about runtime type checking in TypeScript is that it doesn’t actually add any type safety at runtime; it’s a tool for validating data against a schema that mirrors your TypeScript types.
Let’s see Zod in action. Imagine you’re building an API endpoint that accepts user data. You define a Zod schema for this data:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number().int(),
username: z.string().min(1),
email: z.string().email(),
isActive: z.boolean().optional(),
});
// This is a TypeScript type derived from the Zod schema
type User = z.infer<typeof UserSchema>;
// Simulate receiving data from an external source (e.g., an API request body)
const incomingUserData = {
id: 123,
username: "alice",
email: "alice@example.com",
// isActive is missing, which is fine because it's optional
};
try {
// Zod's parse method attempts to validate the incoming data against the schema
const validatedUserData: User = UserSchema.parse(incomingUserData);
console.log("Data is valid:", validatedUserData);
} catch (error) {
console.error("Data validation failed:", error);
}
const invalidUserData = {
id: "not a number", // Incorrect type
username: "", // Fails min(1)
email: "not an email", // Fails email()
};
try {
const validatedInvalidData: User = UserSchema.parse(invalidUserData);
console.log("This won't be printed.");
} catch (error) {
console.error("Data validation failed for invalid data:", error);
}
This code demonstrates the core loop: define a schema, receive data, and use the schema to validate that data. If validation passes, you get a value that is guaranteed to conform to your User type. If it fails, Zod throws a detailed error telling you exactly what went wrong and where.
The problem Zod and Valibot solve is the "trust boundary" problem. TypeScript excels at static type checking during development. It ensures that if you’re passing a User object from one part of your TypeScript code to another, it’s the correct shape. However, when data enters your application from the "outside" – be it an API request, a database query, a file upload, or even user input in a browser – TypeScript has no inherent knowledge of its structure. This incoming data is inherently untrusted.
Zod and Valibot provide a way to define your expected data structure once, in a way that can be used both for TypeScript’s static analysis (generating type or interface definitions) and for runtime validation. Internally, they work by defining a "schema" object. This schema object is a description of your data’s shape and constraints. When you call a method like parse() or safeParse(), the library walks through the incoming data, checking each field against the corresponding part of the schema definition. It performs type coercion where possible (e.g., turning "123" into 123 if the schema expects a number) and throws errors for structural mismatches or constraint violations.
The key levers you control are the schema definitions themselves. You define the expected data types (z.string(), z.number(), z.boolean()), their constraints (.min(1), .max(100), .email(), .url()), whether they are optional (.optional()), if they can be null (.nullable()), and how nested objects and arrays should be structured (z.object({...}), z.array(...)). You can also create more complex schemas using unions (z.union([...])), intersections (z.intersection(...)), and discriminated unions (z.discriminatedUnion(...)).
What most people don’t realize is that Zod and Valibot are not just for parsing JSON. They are incredibly powerful for transforming data as well. For example, you can use the .transform() method to modify data as it passes validation. Consider a scenario where you receive a date as a string but need a JavaScript Date object:
const eventSchema = z.object({
name: z.string(),
timestamp: z.string().datetime().transform((str) => new Date(str)), // Transform string to Date
});
const rawEvent = {
name: "Meeting",
timestamp: "2023-10-27T10:00:00.000Z",
};
const validatedEvent = eventSchema.parse(rawEvent);
console.log(validatedEvent.timestamp instanceof Date); // true
console.log(validatedEvent.timestamp.getFullYear()); // 2023
This .transform() call happens after the z.string().datetime() validation but before the final parsed output is returned. It allows you to clean and shape data into the exact format your application logic expects, all within the same validation step.
The next step after mastering data validation is understanding how to integrate these schemas effectively into your backend frameworks for automatic request validation.