Imagine reviewing a pull request where a function validates user input using both TypeScript types and Zod schemas. You might wonder — isn’t that redundant? But if you’ve ever been burned by a runtime error that slipped past TypeScript, you may also feel tempted to rely on Zod for everything.
The confusion often comes from mixing compile-time and runtime validation. Many developers see TypeScript and Zod as competing tools — but in reality, they complement each other. Each provides a different kind of safety across your application’s lifecycle.
TypeScript ensures type safety during development and the build process, while Zod validates untrusted data at runtime. Knowing when to use one or both helps create more reliable, consistent applications.
TypeScript is your first line of defense, catching errors before they reach production. It provides:
However, TypeScript can’t validate runtime data. Once your application starts running, the types disappear — leaving external inputs unchecked.
Zod fills that gap by validating the data your app receives from the outside world — APIs, forms, configuration files, and more.
Zod follows the “parse, don’t validate” philosophy — it validates and safely transforms data into your expected shape in a single step.
Choosing between TypeScript, Zod, or both depends on your data’s trust boundary:
Context | TypeScript Only | Zod Only | Zod + TypeScript |
---|---|---|---|
Internal utilities | Perfect fit | Not needed | Unnecessary complexity |
Config files / JSON | No runtime safety | Good choice | Best of both worlds |
API boundaries | Runtime blind spot | Missing compile-time safety | Essential |
Complex forms | No validation logic | Handles validation well | Maximum safety |
3rd-party APIs | Dangerous assumption | Protects against changes | Recommended |
Database queries | Shape can vary | Validates results | Type-safe queries |
import { z } from 'zod'; const CreateUserSchema = z.object({ email: z.string().email('Invalid email format'), name: z.string().min(2, 'Name must be at least 2 characters'), age: z.number().int().min(13, 'Must be at least 13 years old'), role: z.enum(['user', 'admin']).default('user') }); type CreateUserRequest = z.infer; const UserResponseSchema = z.object({ id: z.string(), email: z.string(), name: z.string(), age: z.number(), role: z.enum(['user', 'admin']), createdAt: z.date() }); type UserResponse = z.infer; app.post('/users', async (req, res) => { try { const userData = CreateUserSchema.parse(req.body); const user = await createUser(userData); const validatedUser = UserResponseSchema.parse(user); res.json(validatedUser); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ errors: error.errors }); } res.status(500).json({ error: 'Internal server error' }); } }); async function createUser(userData: CreateUserRequest): Promise { const user = { id: generateId(), ...userData, createdAt: new Date() }; await db.users.create(user); return user; }
Why this works:
interface OnboardingForm { personalInfo: { firstName: string; lastName: string; email: string; phone?: string; }; preferences: { newsletter: boolean; notifications: string[]; theme: 'light' | 'dark'; }; account: { username: string; password: string; confirmPassword: string; }; } function submitForm(data: OnboardingForm) { // No validation - what if email is invalid? // No password confirmation check // No way to provide user feedback on errors api.post('/onboard', data); }
Problems:
import { z } from 'zod'; const PersonalInfoSchema = z.object({ firstName: z.string().min(1, 'First name is required'), lastName: z.string().min(1, 'Last name is required'), email: z.string().email('Please enter a valid email'), phone: z.string().regex(/^\+?[\d\s-()]+$/, 'Invalid phone number').optional() }); const PreferencesSchema = z.object({ newsletter: z.boolean(), notifications: z.array(z.enum(['email', 'sms', 'push'])), theme: z.enum(['light', 'dark']) }); const AccountSchema = z.object({ username: z.string() .min(3, 'Username must be at least 3 characters') .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'), 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"] }); const OnboardingFormSchema = z.object({ personalInfo: PersonalInfoSchema, preferences: PreferencesSchema, account: AccountSchema }); type OnboardingForm = z.infer; type PersonalInfo = z.infer; type Preferences = z.infer; type Account = z.infer; function OnboardingForm() { const [formData, setFormData] = useState<Partial>({}); const [errors, setErrors] = useState<Record<string, string>>({}); const validateStep = (stepData: unknown, schema: z.ZodSchema) => { try { schema.parse(stepData); return true; } catch (error) { if (error instanceof z.ZodError) { const fieldErrors: Record<string, string> = {}; error.errors.forEach(err => { const path = err.path.join('.'); fieldErrors[path] = err.message; }); setErrors(fieldErrors); } return false; } }; const submitForm = async (data: OnboardingForm) => { try { const validatedData = OnboardingFormSchema.parse(data); await api.post('/onboard', validatedData); // handle success } catch (error) { if (error instanceof z.ZodError) { // Show validation errors setErrors(/* format errors */); } } }; // Component JSX with error handling... }
Why this is better:
transform()
to reshape data, not just validate it.Choosing between TypeScript, Zod, or both isn’t about competition — it’s about coverage. TypeScript gives you confidence in how your code runs, while Zod ensures the data your code touches is safe and valid.
P.S. Validate trust boundaries, type-check everything else. Your users (and your future self) will thank you.
LogRocket lets you replay user sessions, eliminating guesswork by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks, and with plugins to log additional context from Redux, Vuex, and @ngrx/store.
With Galileo AI, you can instantly identify and explain user struggles with automated monitoring of your entire product experience.
Modernize how you understand your web and mobile apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowDiscover how WebAssembly 3.0’s garbage collector, exception handling, and Memory64 transform Wasm into a true mainstream web platform.
AI agents often break shadcn/ui components with outdated docs or made-up props. The MCP server fixes this by giving live access to registries. In this tutorial, we’ll set it up and build a Kanban board to show it in action.
Learn how to structure Rust web services with clean architecture, Cargo workspaces, and modular crates for scalable, maintainable backends.
Andrew Evans gives his take on agentic AI and walks through a step-by-step method to build a spec-first workflow using Claude Code.