Introduction: The validation confusion
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, whil…
Introduction: The validation confusion
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 vs. Zod: Different types of safety
TypeScript offers static analysis (compile-time)
TypeScript is your first line of defense, catching errors before they reach production. It provides:
- Static analysis: Detects type mismatches and missing properties during development.
- Developer experience: Enables autocomplete, refactoring, and inline documentation.
- No runtime overhead: Type information is removed at compile time. However, TypeScript can’t validate runtime data. Once your application starts running, the types disappear — leaving external inputs unchecked.
Zod for runtime validation
Zod fills that gap by validating the data your app receives from the outside world — APIs, forms, configuration files, and more.
- Runtime validation: Checks data at runtime, not just during development.
- Type inference: Automatically generates TypeScript types from schemas.
- Rich validation logic: Supports complex rules and custom error messages. Zod follows the “parse, don’t validate” philosophy — it validates and safely transforms data into your expected shape in a single step.
Understanding the boundaries in your application
Choosing between TypeScript, Zod, or both depends on your data’s trust boundary:
- Trusted data: Internal functions, controlled components — TypeScript is enough.
- Untrusted data: Anything from external sources (APIs, user input) — use Zod.
Decision matrix: Choosing the right tool
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 |
Example 1: API request and response validation
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<typeof CreateUserSchema>; 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<typeof UserResponseSchema>; 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" }); } });
Why this works:
- Zod validates request and response data at runtime.
- TypeScript infers types automatically — no duplication.
- Internal functions stay type-safe without extra runtime checks.
- Every API response is validated before reaching the client.
Example 2: Complex client form
TypeScript-only approach (not recommended)
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; }; }
Problems:
- No runtime validation of user input
- No way to show validation errors
- Password confirmation logic unenforced
- Invalid email formats slip through
Zod + TypeScript approach (recommended)
import { z } from "zod"; 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"] });
Why this is better:
- Real-time feedback for users
- Type-safe form data handling
- Complex rules like password confirmation
- Automatic TypeScript inference
- Reusable, composable schemas
The tradeoffs
TypeScript-only advantages
- Simpler mental model
- Faster for small internal projects
- No runtime cost
Zod + TypeScript advantages
- Runtime safety with rich feedback
- Complex validation logic support
- Better user and developer experience
When to choose each
- Simple internal forms: TypeScript only
- User-facing forms: Zod + TypeScript
- External data: Always Zod + TypeScript
Best practices and takeaways
When to use TypeScript only
- Internal utilities and business logic
- Component props and controlled state
- Trusted configuration objects
When to use Zod only
- One-off validation scripts
- Quick prototyping
- Runtime-only configs
When to use Both
- API request/response handling
- User input and form validation
- External data ingestion
- Config files that affect app behavior
Pro Tips
- Start with Zod schemas, then infer TypeScript types.
- Use
transform()
to reshape data, not just validate it. - Validate early — at system entry points.
- Cache parsed data to reduce overhead.
- Reuse schemas across client and server when possible.
Conclusion
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 understands everything users do in your web and mobile apps.
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.