After this lesson, you will be able to: Use Zod (or similar) for input validation; return consistent error response shapes; distinguish validation vs server errors.
Inconsistent error responses are the #1 frustration when consuming an API. A consistent shape lets clients build one error handler that works everywhere.
TypeScript-first schema validation. The standard.
// npm i zodimport { z } from 'zod';const createUserSchema = z.object({email: z.string().email(),name: z.string().min(1).max(100),age: z.number().int().min(13).max(120).optional(),role: z.enum(['student', 'tutor', 'admin']).default('student'),});// In your route handlerapp.post('/users', async (req, res) => {const parsed = createUserSchema.safeParse(req.body);if (!parsed.success) {return res.status(422).json({error: 'ValidationError',message: 'Invalid input',details: parsed.error.issues, // array of { path, message, code }});}const user = await db.users.create(parsed.data);res.status(201).location(`/users/${user.id}`).json(user);});
Pick one shape; use it everywhere.
// One shape for ALL errors, clients can rely on it{"error": "ValidationError", // machine-readable code"message": "Email is required", // human-readable summary"details": [ // optional structured details{ "field": "email", "code": "required" },{ "field": "age", "code": "min", "min": 13 }],"requestId": "req_abc123" // for support / debugging}// Use the same shape for 4xx and 5xx; only the status code differs.// Stripe + GitHub + Slack all do variants of this.
Same envelope; different status.
// 400, malformed (bad JSON, missing required field)// 422, well-formed but business-invalid (email already taken)// 500, your bug// 503, depends on something else (DB down)// 4xx: include details client can show the user// 5xx: log everything internally; return a generic message + requestId// Internalconsole.error({ requestId, route: req.path, error: err.stack });// Externalreturn res.status(500).json({error: 'InternalError',message: 'Something went wrong on our end.',requestId,});
Catch-all so you don't repeat yourself.
import { ZodError } from 'zod';app.use((err, req, res, next) => {const requestId = req.headers['x-request-id'] || crypto.randomUUID();if (err instanceof ZodError) {return res.status(422).json({error: 'ValidationError',message: 'Invalid input',details: err.issues.map(i => ({ field: i.path.join('.'), code: i.code, message: i.message })),requestId,});}if (err.statusCode === 401) {return res.status(401).json({ error: 'Unauthorized', message: 'Please sign in', requestId });}// Default 500console.error({ requestId, err });res.status(500).json({ error: 'InternalError', message: 'Internal server error', requestId });});
Returning 200 with `{error: '...'}` (clients can't tell success from failure without parsing). Different error shapes across endpoints. Leaking stack traces in 500 responses (information disclosure). Validating in business logic instead of at the edge (defensive code spreads everywhere).
Sign in and purchase access to unlock this lesson.