Joi vs Zod for Request Validation in Node.js: Which Should You Use?
I stumbled into a production incident at 2 AM because my API received a string "undefined" instead of an actual value. The business logic crashed trying to parse it. That night changed how I think about request validation.
A Reddit post from a developer with 2 years of solo Node.js production experience put it perfectly:
“Joi/Zod validation on every incoming request before it touches any business logic. The number of bugs this prevents is insane.”
This post compares Joi and Zod for request validation in Node.js, based on my own trial and error over the years.
The Core Problem
Without validation at the edge, your API receives unpredictable input:
- Strings where numbers are expected
- Missing required fields
- Malformed data that crashes downstream code
- Security vulnerabilities from mass assignment
Here’s what happened in my early code - no validation, direct business logic:
app.post('/api/users', async (req, res) => { // This will crash if email is missing or malformed const user = await createUser(req.body.email, req.body.name); // undefined.name is not a function - production error});Joi vs Zod: The Key Differences
After using both libraries extensively, here’s what matters:
| Feature | Zod | Joi |
|---|---|---|
| TypeScript Support | Native inference | Requires manual types |
| Bundle Size | ~11KB gzipped | ~50KB gzipped |
| Async Validation | Limited | Native support |
| Learning Curve | Steeper (TS concepts) | Gentler (pure JS) |
| Ecosystem | Growing rapidly | Mature, established |
When Zod Wins: TypeScript Projects
I switched to Zod when I adopted TypeScript. The type inference alone made it worth it.
Define your schema once, and TypeScript gives you the type for free:
const UserSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email(), age: z.number().int().min(0).max(150).optional(), role: z.enum(['admin', 'user', 'guest']).default('user')});
// TypeScript knows this type automaticallytype User = z.infer<typeof UserSchema>;In my route handler, req.body becomes fully typed:
app.post('/users', validateBody(UserSchema), (req: Request, res: Response) => { // req.body is typed as User - autocomplete works! const user: User = req.body;});The compile-time safety caught bugs I used to ship to production. When I changed the schema, TypeScript flagged every place that needed updating.
When Joi Wins: Complex Validation Scenarios
I kept using Joi for scenarios where Zod felt awkward. Joi’s async validation support is straightforward:
const uniqueEmailSchema = Joi.object({ email: Joi.string().email().external(async (value) => { const exists = await checkEmailInDatabase(value); if (exists) { throw new Error('Email already registered'); } return value; })});Cross-field validation is cleaner in Joi:
const passwordSchema = Joi.object({ password: Joi.string().min(8).required(), confirmPassword: Joi.string() .valid(Joi.ref('password')) .required() .messages({ 'any.only': 'Passwords must match' })});Conditional validation is built-in:
const subscriptionSchema = Joi.object({ plan: Joi.string().valid('free', 'pro', 'enterprise').required(), paymentMethod: Joi.when('plan', { is: Joi.string().valid('pro', 'enterprise'), then: Joi.string().required(), otherwise: Joi.optional() })});These patterns exist in Zod but require more verbose refine() calls.
The Validation Middleware Pattern
Regardless of which library you choose, the middleware pattern stays consistent:
Incoming Request ↓ Validation Middleware ← Joi or Zod schema checks ↓ [400 Error] or [Next] ↓ Route Handler (business logic) ↓ ResponseHere’s my Zod middleware:
const validateBody = <T>(schema: z.ZodSchema<T>) => { return (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req.body);
if (!result.success) { return res.status(400).json({ success: false, error: 'Validation failed', issues: result.error.issues.map(issue => ({ path: issue.path.join('.'), message: issue.message })) }); }
req.body = result.data; next(); };};And the Joi equivalent:
const validateBody = (schema) => { return (req, res, next) => { const { error, value } = schema.validate(req.body, { abortEarly: false, stripUnknown: true, convert: true });
if (error) { return res.status(400).json({ success: false, error: 'Validation failed', details: error.details.map(detail => ({ path: detail.path.join('.'), message: detail.message })) }); }
req.body = value; next(); };};My Mistakes and Lessons Learned
1. Not stripping unknown fields
I allowed users to inject extra fields like role: "admin" into registration requests. The fix: use stripUnknown: true in Joi or manually filter in Zod.
2. Returning only the first error
Initially I used abortEarly: true. Users submitted forms, got one error, fixed it, resubmitted, got another error. Frustrating. Switched to abortEarly: false to show all errors at once.
3. Forgetting to replace req.body
After validation transforms data (like applying defaults), I forgot to use the transformed value. Route handlers received raw input, not sanitized data.
4. Not testing validation schemas
I treat validation logic like any other code now. Tests verify that invalid input is rejected and valid input passes through correctly.
Decision Matrix
Choose Zod when:
- You use TypeScript - type inference alone is worth it
- Bundle size matters (serverless, edge functions)
- You want compile-time safety
- You’re in a monorepo sharing types between frontend and backend
Choose Joi when:
- You use plain JavaScript
- You need async validation (database lookups)
- You have complex conditional validation rules
- You need battle-tested stability (Joi has years of production use)
The Bottom Line
The choice matters less than doing validation at all. Every endpoint that accepts user input should validate that input before it reaches business logic.
I validated at the edge in my second year of production, and the difference was night and day. Fewer crashes, cleaner handlers, consistent error responses, and better security.
Install your chosen library, create middleware, and validate everything. Your 3 AM self will thank you.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Zod - TypeScript-first schema validation
- 👨💻 Joi - The most powerful validation library
- 👨💻 Express.js middleware guide
- 👨💻 TypeScript Handbook - Narrowing
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments