By the end of this lesson, you will understand:
- Why client input can never be trusted
- How to validate request bodies with Zod
- How to return consistent error responses
- Best practices for validation schemas
Imagine a user sends this request:
{
"userId": "",
"itemId": "not_an_item",
"qty": -5
}What happens when your code tries to:
- Reserve a negative quantity?
- Look up an item with an invalid ID?
- Process an empty user ID?
Without validation, your application might:
- Crash with unhelpful errors
- Create invalid data in the database
- Expose implementation details to attackers
Use Zod to validate all input before it reaches your business logic.
import { z } from 'zod';
export const reserveRequestSchema = z.object({
userId: z.string().min(1), // Required, non-empty
itemId: z.string().regex(/^item_\d+$/), // Must match format
qty: z.number().int().min(1).max(5) // 1-5, integer
});- Define schema - Declare what valid input looks like
- Parse request - Check if request body matches schema
- Return error - Send validation error if invalid
- Use data - TypeScript knows data is valid
File: src/routes/index.ts
const parsed = validateRequest(reserveRequestSchema, req.body);
if (!parsed.ok) {
return badRequest(res, 'VALIDATION_ERROR', 'Invalid request', {
issues: parsed.error.details
});
}
// TypeScript now knows these are valid strings/numbers
const { userId, itemId, qty } = parsed.data;const schema = z.object({
qty: z.number()
});
type Input = z.infer<typeof schema>;
// { qty: number }const result = schema.safeParse({ qty: "5" });
if (!result.success) {
// Handle error
console.log(result.error); // ZodError with details
}
// result.data is now { qty: 5 } (coerced to number)const nonEmptyString = z.string().min(1);
const itemId = nonEmptyString.regex(/^item_\d+$/);
const itemName = nonEmptyString.max(100);z.string().min(1) // Non-empty string
z.number().positive() // Must be > 0z.string().optional() // Can be undefined
z.string().nullable() // Can be null
z.string().nullish() // Can be null or undefinedz.string().email() // Email address
z.string().url() // URL
z.string().regex(/^[A-Z]{2}$/) // Custom regexz.number().min(1).max(100) // 1-100
z.array().min(1).max(10) // 1-10 items
z.enum(['reserved', 'confirmed']) // Must be one of theseapp.post('/reserve', (req, res) => {
const parsed = validateRequest(reserveRequestSchema, req.body);
if (!parsed.ok) return sendError(parsed.error);
// Business logic only sees valid data
reserveItem(parsed.data);
});// Don't do this!
function reserveItem(data: any) {
if (!data.userId || typeof data.userId !== 'string') {
throw new Error('Invalid userId');
}
// Business logic shouldn't handle validation
}z.number()
.min(1, 'Minimum quantity is 1')
.max(5, 'Maximum quantity is 5')// Bad: Shows database schema
"Column user_id cannot be null"
// Good: User-friendly message
"User ID is required"Try these requests to see validation in action:
curl -X POST http://localhost:3000/api/v1/reserve \
-H "Content-Type: application/json" \
-d '{"userId":"user_1","itemId":"item_1","qty":2}'curl -X POST http://localhost:3000/api/v1/reserve \
-H "Content-Type: application/json" \
-d '{"userId":"","itemId":"item_1","qty":2}'
# Response: 400 {"ok":false,"error":{"code":"VALIDATION_ERROR",...}}curl -X POST http://localhost:3000/api/v1/reserve \
-H "Content-Type: application/json" \
-d '{"userId":"user_1","itemId":"item_1","qty":10}'
# Response: 400 {"ok":false,"error":{"code":"VALIDATION_ERROR","..."}}curl -X POST http://localhost:3000/api/v1/reserve \
-H "Content-Type: application/json" \
-d '{"userId":"user_1","itemId":"invalid","qty":1}'
# Response: 400 {"ok":false,"error":{"code":"VALIDATION_ERROR",...}}| File | Purpose |
|---|---|
src/validation/schemas.ts |
All validation schemas |
src/types/index.ts |
Result types for validation |
src/http/index.ts |
badRequest() helper |
src/routes/index.ts |
Schema usage in endpoints |
Task: Add an email field to the user schema.
- Open
src/validation/schemas.ts - Create a user schema with email validation:
export const userSchema = z.object({ userId: z.string().min(1), email: z.string().email() });
- Test with valid and invalid emails
- Validate early - At the API boundary, before business logic
- Use schemas - Declarative validation is easier to maintain
- Return consistent errors - Same format everywhere
- Be specific - Clear error messages help developers
Continue to Lesson 3: Concurrency & Atomic Operations to learn how to prevent race conditions when multiple users interact with your API simultaneously.
💡 Tip: Run npm run dev and use the examples above to see validation errors in action!