In the rapidly evolving world of web development, the intersection of strongly-typed systems and AI-powered features presents unique challenges. As developers, we're tasked with maintaining type safety and data integrity across an increasingly complex ecosystem of modular components, third-party services, and asynchronous communication patterns.
The integration of large language models (LLMs) adds another layer of complexity, introducing highly dynamic and potentially unpredictable data structures into our carefully typed systems.
Zod emerges as a powerful solution in this landscape. As a TypeScript-first schema declaration and validation library, it offers a flexible yet robust approach to ensuring type safety across the stack.
Unlike traditional type systems that operate solely at compile-time, Zod provides runtime validation, bridging the gap between static typing and the dynamic nature of AI-generated content.
These following examples will highlight Zod's versatility in enforcing strict type requirements, gracefully managing scenarios where data doesn't meet expected schemas, and providing a consistent approach to data validation across different parts of a web application.
A simple user schema:
import { z } from 'zod'; // Define the address schema const AddressSchema = z.object({ street: z.string().min(1, "Street is required"), city: z.string().min(1, "City is required"), state: z.string().length(2, "State must be a 2-letter code"), zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid ZIP code format"), country: z.string().min(1, "Country is required"), }); // Define the user schema const UserSchema = z.object({ id: z.string().uuid("Invalid user ID"), firstName: z.string().min(1, "First name is required"), lastName: z.string().min(1, "Last name is required"), email: z.string().email("Invalid email address"), dateOfBirth: z.date(), phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number"), shippingAddress: AddressSchema, billingAddressSameAsShipping: z.boolean(), billingAddress: AddressSchema.optional(), }).refine((data) => { if (data.billingAddressSameAsShipping) { return data.billingAddress === undefined; } return data.billingAddress !== undefined; }, { message: "Billing address must be provided if different from shipping address", path: ["billingAddress"], }); // Infer the TypeScript type from the schema type User = z.infer<typeof UserSchema>;
What is Zod?
Zod operates by creating runtime type checks based on schema definitions. When you define a Zod schema, you're essentially creating a blueprint for data validation. Under the hood, Zod uses these schemas to generate functions that perform type checks and coercions on data at runtime. This allows Zod to catch type errors that might slip through static type checking, especially when dealing with external data sources or dynamic content.
Zod under the hood:
// ZodType is the base class for all Zod schemas class ZodType<T> { _parse(input: unknown): { success: true; data: T } | { success: false; error: string } { throw new Error("Not implemented"); } } // ZodString implements the string schema class ZodString extends ZodType<string> { private minLength: number | null = null; private maxLength: number | null = null; // Method to set minimum length min(length: number): this { this.minLength = length; return this; } // Method to set maximum length max(length: number): this { this.maxLength = length; return this; } // Implementation of the _parse method _parse(input: unknown): { success: true; data: string } | { success: false; error: string } { // Check if input is a string if (typeof input !== "string") { return { success: false, error: "Expected string, got " + typeof input }; } // Check minimum length if (this.minLength !== null && input.length < this.minLength) { return { success: false, error: `String must be at least ${this.minLength} characters long` }; } // Check maximum length if (this.maxLength !== null && input.length > this.maxLength) { return { success: false, error: `String must be at most ${this.maxLength} characters long` }; } // If all checks pass, return success return { success: true, data: input }; } } // Helper function to create a string schema function z_string(): ZodString { return new ZodString(); } // Usage example const nameSchema = z_string().min(2).max(50); console.log(nameSchema._parse("Alice")); // { success: true, data: "Alice" } console.log(nameSchema._parse("")); // { success: false, error: "String must be at least 2 characters long" } console.log(nameSchema._parse(123)); // { success: false, error: "Expected string, got number" }
The key difference between Zod and TypeScript's type system is that Zod provides runtime type checking, while TypeScript's types are erased at compile time. This means that while TypeScript can catch type errors during development, it can't guarantee type safety for data that comes from external sources or is generated at runtime.
Zod fills this gap by allowing you to define schemas that can validate data at runtime, ensuring that your application remains type-safe even when interacting with unpredictable data sources. Additionally, Zod's schemas can be used to generate TypeScript types, creating a bridge between runtime validation and static typing.
Various Zod examples
1. Complex form verification without client javascript
In scenarios where client-side JavaScript is unavailable or disabled, such as in Tor browsers or high-security environments, it's crucial to implement robust server-side form validation. This approach ensures data integrity and provides a seamless user experience across all platforms. By leveraging Next.js's server-side rendering (SSR) capabilities along with Zod for validation, we can create a system that validates form submissions on the server, returns specific error messages for faulty fields, and re-renders the form with these error messages. This method not only enhances security but also improves accessibility, making your application more inclusive and resilient.
Here's an example of how this could be implemented in Next.js:
// pages/register.tsx import { NextPage } from 'next'; import { z } from 'zod'; const UserSchema = z.object({ username: z.string().min(3, "Username must be at least 3 characters"), email: z.string().email("Invalid email address"), password: z.string().min(8, "Password must be at least 8 characters"), confirmPassword: z.string(), }).refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"], }); type UserForm = z.infer<typeof UserSchema>; interface Props { errors?: Partial<Record<keyof UserForm, string>>; values?: Partial<UserForm>; } const RegisterPage: NextPage<Props> = ({ errors = {}, values = {} }) => { return ( <form method="POST" action="/api/register"> <div> <label htmlFor="username">Username:</label> <input type="text" id="username" name="username" defaultValue={values.username || ''} /> {errors.username && <p className="error">{errors.username}</p>} </div> <div> <label htmlFor="email">Email:</label> <input type="email" id="email" name="email" defaultValue={values.email || ''} /> {errors.email && <p className="error">{errors.email}</p>} </div> <div> <label htmlFor="password">Password:</label> <input type="password" id="password" name="password" /> {errors.password && <p className="error">{errors.password}</p>} </div> <div> <label htmlFor="confirmPassword">Confirm Password:</label> <input type="password" id="confirmPassword" name="confirmPassword" /> {errors.confirmPassword && <p className="error">{errors.confirmPassword}</p>} </div> <button type="submit">Register</button> </form> ); }; export default RegisterPage; // pages/api/register.ts import { NextApiRequest, NextApiResponse } from 'next'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'POST') { const result = UserSchema.safeParse(req.body); if (!result.success) { const errors = result.error.flatten().fieldErrors; return res.redirect( `/register?errors=${encodeURIComponent(JSON.stringify(errors))}&values=${encodeURIComponent(JSON.stringify(req.body))}` ); } // Process valid registration... res.redirect('/registration-success'); } else { res.status(405).json({ message: 'Method not allowed' }); } } // pages/register.tsx (getServerSideProps) export async function getServerSideProps(context) { const { query } = context; const errors = query.errors ? JSON.parse(decodeURIComponent(query.errors as string)) : {}; const values = query.values ? JSON.parse(decodeURIComponent(query.values as string)) : {}; return { props: { errors, values }, }; }
2. Nostr event verification:
Nostr, as an emerging protocol, presents unique challenges in data validation due to its evolving standards and diverse ecosystem of clients. The lack of strict enforcement in event structure can lead to inconsistencies, making it crucial to implement robust validation mechanisms.
This is particularly important when dealing with specific event types like calendar events (NIP-52). By using Zod, we can create a flexible yet strict schema that adheres to the NIP-52 standard while gracefully handling the inherent fuzziness of Nostr events. This approach allows us to filter out malformed or non-standard events, ensuring that only valid calendar events are processed and stored in our database for further use.
Here's a code example demonstrating this approach:
import { z } from 'zod'; import { relayInit, Event } from 'nostr-tools'; import { PrismaClient } from '@prisma/client'; // Prisma client initialization const prisma = new PrismaClient(); // Zod schema for NIP-52 calendar event const CalendarEventSchema = z.object({ id: z.string().length(64), pubkey: z.string().length(64), created_at: z.number().int().positive(), kind: z.literal(31922), tags: z.array(z.tuple([ z.literal('d'), z.string(), ])).nonempty(), content: z.object({ title: z.string().min(1), start: z.number().int().positive(), end: z.number().int().positive().optional(), location: z.string().optional(), description: z.string().optional(), image: z.string().url().optional(), }).transform(JSON.stringify), sig: z.string().length(128), }).strict(); type CalendarEvent = z.infer<typeof CalendarEventSchema>; async function processEvent(event: Event) { try { const validatedEvent = CalendarEventSchema.parse(event); await prisma.calendarEvent.create({ data: { ...validatedEvent, content: JSON.parse(validatedEvent.content), // Parse back to object for storage }, }); console.log(`Event ${validatedEvent.id} processed and stored.`); } catch (error) { if (error instanceof z.ZodError) { console.warn(`Invalid event: ${JSON.stringify(error.errors)}`); } else { console.error(`Error processing event: ${error}`); } } } async function startEventWatcher() { const relay = relayInit('wss://your-relay-url.com'); await relay.connect(); relay.sub([ { kinds: [31922], } ]).on('event', processEvent); console.log('Watcher started for calendar events.'); } startEventWatcher().catch(console.error);
3. Type-Safe Frontends for Event- Driven Systems
In event-driven architectures built with frameworks like Axon IQ, real-time updates are often crucial. Server-Sent Events (SSE) provide an excellent mechanism for pushing events from the server to the client as they occur.
By combining SSE with Zod for runtime type checking, we can create a responsive and type-safe frontend that dynamically renders different components based on incoming event types. This approach not only ensures data integrity but also provides a seamless, real-time user experience.
The following example demonstrates how to implement an event table that subscribes to an SSE stream, processes various event types, and renders appropriate components, all while maintaining strict type safety.
Here's an example of how this could be implemented:
import React from 'react'; import { z } from 'zod'; // Define schemas for different event types (same as before) const BaseEventSchema = z.object({ eventId: z.string().uuid(), timestamp: z.number(), aggregateId: z.string(), }); const UserCreatedEventSchema = BaseEventSchema.extend({ type: z.literal('UserCreatedEvent'), data: z.object({ userId: z.string(), username: z.string(), email: z.string().email(), }), }); const OrderPlacedEventSchema = BaseEventSchema.extend({ type: z.literal('OrderPlacedEvent'), data: z.object({ orderId: z.string(), userId: z.string(), totalAmount: z.number(), }), }); const PaymentProcessedEventSchema = BaseEventSchema.extend({ type: z.literal('PaymentProcessedEvent'), data: z.object({ paymentId: z.string(), orderId: z.string(), amount: z.number(), status: z.enum(['success', 'failure']), }), }); const EventSchema = z.discriminatedUnion('type', [ UserCreatedEventSchema, OrderPlacedEventSchema, PaymentProcessedEventSchema, ]); type Event = z.infer<typeof EventSchema>; // Components for rendering different event types (same as before) const UserCreatedRow: React.FC<z.infer<typeof UserCreatedEventSchema>> = ({ data, timestamp }) => ( <tr> <td>User Created</td> <td>{new Date(timestamp).toLocaleString()}</td> <td>{data.username}</td> <td>{data.email}</td> <td>-</td> </tr> ); const OrderPlacedRow: React.FC<z.infer<typeof OrderPlacedEventSchema>> = ({ data, timestamp }) => ( <tr> <td>Order Placed</td> <td>{new Date(timestamp).toLocaleString()}</td> <td>{data.userId}</td> <td>{data.orderId}</td> <td>${data.totalAmount.toFixed(2)}</td> </tr> ); const PaymentProcessedRow: React.FC<z.infer<typeof PaymentProcessedEventSchema>> = ({ data, timestamp }) => ( <tr> <td>Payment Processed</td> <td>{new Date(timestamp).toLocaleString()}</td> <td>{data.orderId}</td> <td>{data.status}</td> <td>${data.amount.toFixed(2)}</td> </tr> ); // Main component for rendering the event table const EventTable: React.FC = () => { const [events, setEvents] = React.useState<Event[]>([]); React.useEffect(() => { const eventSource = new EventSource('/api/events/stream'); eventSource.onmessage = (event) => { try { const parsedEvent = EventSchema.parse(JSON.parse(event.data)); setEvents((prevEvents) => [parsedEvent, ...prevEvents].slice(0, 100)); // Keep last 100 events } catch (error) { console.error('Error parsing event:', error); } }; eventSource.onerror = (error) => { console.error('EventSource failed:', error); }; return () => { eventSource.close(); }; }, []); const renderEventRow = (event: Event) => { switch (event.type) { case 'UserCreatedEvent': return <UserCreatedRow key={event.eventId} {...event} />; case 'OrderPlacedEvent': return <OrderPlacedRow key={event.eventId} {...event} />; case 'PaymentProcessedEvent': return <PaymentProcessedRow key={event.eventId} {...event} />; } }; return ( <table> <thead> <tr> <th>Event Type</th> <th>Timestamp</th> <th>Detail 1</th> <th>Detail 2</th> <th>Amount</th> </tr> </thead> <tbody> {events.map(renderEventRow)} </tbody> </table> ); }; export default EventTable;
4. Zod-Powered Schema Mapping from Frontend to Backend
In modern web applications, ensuring data consistency between the frontend and backend is crucial. While frontend validation provides immediate user feedback, backend validation is essential for data integrity. This example demonstrates how to use Zod to create a robust data pipeline that validates form data on the frontend, transforms it to match backend requirements, and ensures type safety throughout the process. By leveraging Zod's schema transformation capabilities, we can effortlessly map data structures between different schemas, providing a flexible and type-safe approach to handling data across the stack. This technique is particularly useful when frontend and backend data models evolve independently or when working with legacy systems that require specific data structures.
Here's an example implementation:
Frontend:
import React from 'react'; import { z } from 'zod'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; // Frontend user schema const UserFormSchema = z.object({ fullName: z.string().min(2, "Full name must be at least 2 characters"), email: z.string().email("Invalid email address"), age: z.number().int().min(18, "Must be at least 18 years old"), preferences: z.object({ newsletter: z.boolean(), theme: z.enum(["light", "dark"]), }), }); type UserForm = z.infer<typeof UserFormSchema>; const UserForm: React.FC = () => { const { register, handleSubmit, formState: { errors } } = useForm<UserForm>({ resolver: zodResolver(UserFormSchema), }); const onSubmit = async (data: UserForm) => { try { const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!response.ok) throw new Error('Submission failed'); alert('User created successfully!'); } catch (error) { console.error('Error:', error); alert('Failed to create user'); } }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register("fullName")} placeholder="Full Name" /> {errors.fullName && <span>{errors.fullName.message}</span>} <input {...register("email")} placeholder="Email" /> {errors.email && <span>{errors.email.message}</span>} <input {...register("age", { valueAsNumber: true })} placeholder="Age" type="number" /> {errors.age && <span>{errors.age.message}</span>} <label> <input {...register("preferences.newsletter")} type="checkbox" /> Subscribe to newsletter </label> <select {...register("preferences.theme")}> <option value="light">Light</option> <option value="dark">Dark</option> </select> <button type="submit">Submit</button> </form> ); }; export default UserForm; ``` _Backend:_ ```typescript import express from 'express'; import { z } from 'zod'; const app = express(); app.use(express.json()); // Backend user schema const BackendUserSchema = z.object({ name: z.object({ first: z.string(), last: z.string(), }), emailAddress: z.string().email(), yearOfBirth: z.number().int(), settings: z.object({ emailSubscription: z.boolean(), uiPreferences: z.object({ colorMode: z.enum(["light", "dark"]), }), }), }); type BackendUser = z.infer<typeof BackendUserSchema>; // Transformation schema const TransformSchema = UserFormSchema.transform((data): BackendUser => { const [firstName, ...lastNameParts] = data.fullName.split(' '); const lastName = lastNameParts.join(' '); const currentYear = new Date().getFullYear(); return BackendUserSchema.parse({ name: { first: firstName, last: lastName }, emailAddress: data.email, yearOfBirth: currentYear - data.age, settings: { emailSubscription: data.preferences.newsletter, uiPreferences: { colorMode: data.preferences.theme, }, }, }); }); app.post('/api/users', (req, res) => { try { const backendUser = TransformSchema.parse(req.body); // Here you would typically save `backendUser` to your database console.log('Processed user:', backendUser); res.status(201).json({ message: 'User created successfully' }); } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ errors: error.errors }); } else { res.status(500).json({ message: 'Internal server error' }); } } }); app.listen(3000, () => console.log('Server running on port 3000'));
5. Type-Safe Function Calling with Zod
Large Language Models (LLMs) have revolutionized many aspects of software development, but their flexibility can sometimes lead to unpredictable outputs. When integrating LLMs into strongly-typed systems, ensuring the consistency and validity of their responses becomes crucial.
This example demonstrates how to use Zod to create a robust, type-safe interface for LLM function calls. By defining strict schemas for expected outputs and leveraging Zod's parsing capabilities, we can guarantee that the LLM's responses adhere to our application's type requirements.
When the parsing fails, our system automatically re-queries the LLM, creating a self-correcting mechanism that bridges the gap between the fluid nature of LLMs and the rigid requirements of type-safe programming.
Code Sample:
import { z } from 'zod'; import fetch from 'node-fetch'; // Define schemas for two possible LLM response types const WeatherResponseSchema = z.object({ type: z.literal('weather'), temperature: z.number(), condition: z.enum(['sunny', 'cloudy', 'rainy', 'snowy']), }); const NewsResponseSchema = z.object({ type: z.literal('news'), headline: z.string(), source: z.string(), }); // Union schema for all possible responses const LLMResponseSchema = z.discriminatedUnion('type', [ WeatherResponseSchema, NewsResponseSchema, ]); type LLMResponse = z.infer<typeof LLMResponseSchema>; // Function to query Ollama and parse response async function queryOllama(prompt: string, maxAttempts: number = 3): Promise<LLMResponse> { const ollamaEndpoint = 'http://localhost:11434/api/generate'; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const response = await fetch(ollamaEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'llama3-uncensored', prompt: `You are a helpful assistant that provides weather or news information. Please respond in JSON format with either a weather report or a news headline. For weather, include "type": "weather", "temperature" (a number), and "condition" (sunny, cloudy, rainy, or snowy). For news, include "type": "news", "headline" (a string), and "source" (a string). Respond to this prompt: ${prompt}`, stream: false, }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); const llmResponse = data.response; // Try to parse the LLM's response as JSON let jsonResponse; try { jsonResponse = JSON.parse(llmResponse); } catch (error) { console.warn(`Attempt ${attempt}: LLM response is not valid JSON:`, llmResponse); continue; // Retry with next attempt } const parsedResponse = LLMResponseSchema.safeParse(jsonResponse); if (parsedResponse.success) { return parsedResponse.data; } else { console.warn(`Attempt ${attempt}: Invalid LLM response:`, parsedResponse.error); if (attempt === maxAttempts) { throw new Error("Max attempts reached. Could not get a valid response from LLM."); } } } catch (error) { console.error(`Attempt ${attempt}: Error querying Ollama:`, error); if (attempt === maxAttempts) { throw error; } } } throw new Error("Unexpected error in queryOllama"); } // Example usage async function main() { try { const response = await queryOllama("What's the latest news?"); if (response.type === 'weather') { console.log(`The weather is ${response.condition} with a temperature of ${response.temperature}°C`); } else if (response.type === 'news') { console.log(`Breaking news: ${response.headline} (Source: ${response.source})`); } } catch (error) { console.error("Failed to get a valid response:", error); } } main();
Zod is great for web applications
Zod stands as a powerful ally in the quest for type-safe, robust web applications. It bridges the gap between static typing and runtime validation, offering a unified approach to data integrity across the full stack. From taming the fuzziness of event-driven systems to smoothing the data flow between frontend and backend, Zod empowers developers to write code that's not just functional, but confidently correct.
By embracing Zod, we're not just catching bugs—we're preventing them. We're not just validating data—we're sculpting it. In the ever-evolving landscape of web development, Zod isn't just a tool; it's a mindset. It's about building systems that are resilient by design, where data flows seamlessly and safely from user input to database storage and back again.
In the end, Zod isn't just making our code safer—it's making our development experience smoother, our applications more reliable, and our users happier. That's the real power of type-safe everything.