Build Type-Safe Form Validators in TypeScript with Zod
Master runtime form validation in TypeScript using Zod. Learn to build type-safe validators that catch errors at compile-time and runtime with practical examples.
While I was reviewing some form handling code at work the other day, I came across a pattern that made me cringe—type assertions everywhere, no runtime validation, and crossing my fingers that API responses matched my TypeScript interfaces. Little did I know that there's a much better way to handle this with Zod, and I cannot stress this enough—it completely changed how I approach type safety in my applications.
In today's world of API-driven applications, understanding runtime validation isn't just nice to have—it's essential. In this post, we'll go over how to build type-safe form validators in TypeScript using Zod, and you'll learn patterns that will help you catch bugs before they reach production.
The Problem: TypeScript's Runtime Blindspot
I was once guilty of writing code like this, thinking I was being safe with TypeScript:
// Bad - TypeScript doesn't validate at runtime
interface User {
email: string
age: number
}
const userData = await fetch('/api/user').then(r => r.json())
const user: User = userData // ⚠️ Type assertion, no validation!
console.log(user.email.toLowerCase()) // 💥 Runtime error if email is nullHere's the thing that caught me off guard: TypeScript is a compile-time tool. Once your code is transpiled to JavaScript, all those beautiful types are gone. Make no mistake about it—if the API returns malformed data, your application will crash, and TypeScript won't save you.

Recently, there was a colleague who maintained a form validation system that I had to take over. When I looked at the code, I realized why errors kept slipping through—there was no runtime validation at all. The application assumed that user input would always match the expected types, which, as we all know, is never the case.
Enter Zod: Runtime Validation That Speaks TypeScript
Zod is a TypeScript-first validation library where you define a schema once and get both runtime validation and TypeScript types automatically. If you're familiar with generics in TypeScript, you'll appreciate how Zod leverages them for powerful type inference.
Here's how we can fix the previous example:
// Better - Runtime validation with Zod
import { z } from 'zod'
const UserSchema = z.object({
email: z.string().email(),
age: z.number().min(18)
})
const result = UserSchema.safeParse(userData)
if (!result.success) {
console.error(result.error) // Handle validation errors
} else {
const user = result.data // ✅ Type-safe AND validated
console.log(user.email.toLowerCase()) // Safe!
}Wonderful! Now we have both compile-time type safety and runtime validation. The .safeParse() method returns a discriminated union—either success with valid data or failure with detailed errors. No try/catch blocks needed, and the code reads cleanly.
Building a Type-Safe Form Validator
Let's build a practical signup form validator step by step. We'll create a schema that validates email, password, and age fields:
// Advanced - Custom validation with refine()
const PasswordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.refine(
(password) => /[A-Z]/.test(password),
'Password must contain at least one uppercase letter'
)
.refine(
(password) => /[0-9]/.test(password),
'Password must contain at least one number'
)
const SignupSchema = z.object({
email: z.string().email('Invalid email address'),
password: PasswordSchema,
age: z.coerce.number().min(18, 'Must be 18 or older')
})
type SignupForm = z.infer<typeof SignupSchema> // ✅ Inferred typeIn other words, we define our validation rules once, and TypeScript automatically knows what the SignupForm type looks like. This is the power of Zod's type inference—no duplicate definitions!

Notice the z.coerce.number() for the age field? This is Zod's coercion feature in action. Form inputs always come as strings, but Zod automatically converts them to numbers before validation. Luckily we can handle this transformation declaratively rather than manually parsing strings everywhere.
Advanced Patterns: Custom Validation & Transformations
When I finally decided to explore Zod's advanced features, I discovered patterns that made complex validations elegant. Let's look at how to handle custom validation rules with .refine():
const PasswordSchema = z.string()
.min(8)
.refine(
(password) => /[A-Z]/.test(password) && /[0-9]/.test(password),
{
message: 'Password must contain at least one uppercase letter and one number'
}
)The .refine() method takes a validation function and an error message. This is perfect for business logic that goes beyond simple type checking—like password strength rules, date ranges, or cross-field validation.
Zod also provides transformation utilities that are incredibly useful for reusing schemas:
// Create a base user schema
const UserSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
bio: z.string().optional(),
age: z.number().min(18)
})
// Create an "edit profile" schema that makes all fields optional
const EditProfileSchema = UserSchema.partial()
// Or pick only specific fields for a "quick signup" form
const QuickSignupSchema = UserSchema.pick({ email: true, name: true })
// Or omit sensitive fields for public display
const PublicProfileSchema = UserSchema.omit({ email: true })These transformation methods (.partial(), .pick(), .omit()) create new schemas derived from your base schema, keeping your validation logic DRY and maintainable.
Error Handling Like a Pro
When validation fails, you want to show users helpful error messages, not cryptic technical jargon. Here's how to format Zod errors for display:
// Helper function for user-friendly error messages
function formatZodErrors(error: z.ZodError): Record<string, string> {
const errors: Record<string, string> = {}
error.errors.forEach((err) => {
const path = err.path.join('.')
errors[path] = err.message
})
return errors
}
// Usage in your form handler
const result = SignupSchema.safeParse(formData)
if (!result.success) {
const errors = formatZodErrors(result.error)
// {
// email: 'Invalid email address',
// password: 'Password must contain at least one uppercase letter',
// age: 'Must be 18 or older'
// }
return { errors }
}
// Success path
const validData = result.data
// ... proceed with valid, type-safe dataFor more on error handling patterns beyond validation, check out our guide on best practices to control your errors.
Testing Your Validators
I cannot stress this enough—validators are critical business logic and deserve thorough testing. Luckily we can test validators independently of the UI, which makes tests fast and focused:
import { describe, it, expect } from 'vitest'
describe('SignupSchema', () => {
it('should reject invalid email addresses', () => {
const result = SignupSchema.safeParse({
email: 'not-an-email',
password: 'Password123',
age: 25
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.errors[0].path).toEqual(['email'])
}
})
it('should reject weak passwords', () => {
const result = SignupSchema.safeParse({
email: 'user@example.com',
password: 'weak',
age: 25
})
expect(result.success).toBe(false)
if (!result.success) {
const passwordError = result.error.errors.find(
err => err.path[0] === 'password'
)
expect(passwordError?.message).toContain('8 characters')
}
})
it('should accept valid signup data', () => {
const result = SignupSchema.safeParse({
email: 'user@example.com',
password: 'StrongPass123',
age: 25
})
expect(result.success).toBe(true)
})
it('should coerce string age to number', () => {
const result = SignupSchema.safeParse({
email: 'user@example.com',
password: 'StrongPass123',
age: '25' // String input
})
expect(result.success).toBe(true)
if (result.success) {
expect(typeof result.data.age).toBe('number')
expect(result.data.age).toBe(25)
}
})
})We covered testing best practices in our post on 5 test integrity rules for AI agents, and these same principles apply to validator testing—test edge cases, boundary conditions, and both success and failure paths.
Conclusion
And that concludes the end of this post! I hope you found this valuable and look out for more in the future!
Zod bridges the gap between TypeScript's compile-time type safety and the runtime reality of handling external data. By defining schemas once, you get both validation logic and TypeScript types, eliminating duplicate code and potential mismatches between your types and validation rules.
Working smart is the way to go—use Zod to validate untrusted data at system boundaries (API responses, form submissions, environment variables), and trust TypeScript for internal type safety. This layered approach gives you the best of both worlds: development-time type checking and runtime data validation.
Continue Learning:
- Generics in TypeScript - Deep dive into generic types that power Zod's type inference
- Best Practices to Control Your Errors - Error handling patterns beyond validation
- 5 Test Integrity Rules for AI Agents - Testing best practices for validators
Photo Credits:
- Matrix code background by Markus Spiske on Pexels
- Desk setup by Pixabay on Pexels
- Thumbnail illustration by Google DeepMind on Pexels