Warming up the neural circuits...
By the end of this chapter you will:
is the single most security-relevant thing you'll write. Error handling is the single most debuggability-relevant. Both are boring. Both must be perfect.
You land at an airport and fill out a customs . The customs officer doesn't trust your handwriting. They check that you wrote a passport number that fits the format, that you didn't claim 0 kg of luggage, that the date is in the past. If any field is wrong, they hand you the form back with a note: "Field 3 — date must be DD/MM/YYYY." Specific, actionable, in English.
That's validation. That's good error reporting. You never bring uncertain data into the country.
A client can:
Your server must validate everything, every time. Validation belongs on the server. The frontend can validate too — but only for UX, not for safety.
We'll use Zod because it gives you runtime validation and types from one schema.
npm install zodDefine a schema for "create note":
import { z } from 'zod';
export const createNoteSchema = z.object({
title: z.string().min(1).max(200),
body: z.
That schema says:
title is a string, 1–200 chars, required.body is a string up to 10k chars, optional, defaults to "".tags is an array of strings (each non-empty), up to 20, optional.The TypeScript type comes for free with z.infer<>. One source of truth.
Use it in a route:
import { createNoteSchema } from './schemas/notes';
router.post('/', (req, res, next) => {
const result = createNoteSchema.safeParse(req.body
Now data is fully typed. Garbage in returns a structured 422.
Don't duplicate the safeParse block in every route. Make it a middleware:
import { ZodSchema } from 'zod';
export function validate(schema: ZodSchema, where: 'body' | 'query' | 'params' = 'body') {
return (req
Use:
router.post('/', validate(createNoteSchema), handler);
router.get('/', validate(listNotesQuery, 'query'), handler);Clean. Reusable. Easy to test.
This is the single most useful framing in error handling:
undefined. Bad . Forgot to await.) → Crash; let the orchestrator restart.Mixing them up is why some services swallow their own crashes and keep serving broken responses.
class ApiError extends Error {
constructor(public status: number, message: string, public code?: string) {
super(message);
}
}
// Throw it from a service:
The error middleware sees the status, returns it. Anything not an ApiError becomes a 500 and triggers an alert.
import { ZodError } from 'zod';
export function errorHandler(err, req, res, _next) {
// Zod (in case validation slipped through)
if (err instanceof ZodError) {
return res
Wire it in last:
app.use(errorHandler);Now any thrown ApiError becomes a clean JSON response. Any unexpected error becomes a 500, gets logged, and surfaces a request ID the user can quote.
Frontends love consistent shapes. Pick one and stick to it:
{
"error": "Email is already in use",
"code": "EMAIL_TAKEN",
"requestId": "01HN4R...",
"issues": [
{ "path": ["email"], "message": "
error — human-readable.code — machine-readable (frontend i18n).requestId — so the user can quote it.issues — for multi-field validation.Bad shapes you'll see in the wild:
"error: User already exists"A bare string. Frontends can't internationalize.
{ "status": 200, "error": true, "msg": "User already exists" }A 200 carrying a "failure." Tooling can't tell.
Express 4 doesn't catch throws. Use the asyncHandler wrapper from chapter 5:
const wrap = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
router.post('
Now any throw new ApiError(...) inside notesService.create flows to the error middleware.
Query strings are always strings — you'll often need to coerce:
const listNotesQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string()
z.coerce.number() parses "20" into 20. After validation, req.query.limit is a number.
For UUID route params:
const idParam = z.object({ id: z.string().uuid() });
router.get('/:id', validate(idParam, 'params'), handler);Now invalid UUIDs never reach your handler. Free SQL-injection defense.
Database libraries (Prisma, pg) throw specific error types. Translate them to your ApiError:
try {
return await prisma.user.create({ data });
} catch (err) {
if (err.code === 'P2002') {
throw new ApiError(409, '
Don't leak DB error messages directly. They sometimes contain schema info that helps attackers.
Returning 500 for validation failures. Validation failures are the client's fault (4xx). 500s page the on-call engineer.
| Mistake | Why it's wrong | What to do |
|---|---|---|
Validation only on req.body | Query and params are untrusted too | Validate all three |
| Returning raw error. | Leaks file paths, sometimes secrets | Hide stacks; include requestId |
200 with { error } | Tooling can't tell pass/fail | Use the right status |
| try/catch every line | Hard to read | Throw at the service; catch once at the middleware |
Ignoring process.on('unhandledRejection') | Lost errors | Listen and log; consider exiting |
Production. Wire unhandledRejection and uncaughtException to log + exit. Let the orchestrator restart you. A zombie Node process is worse than a restart.
process.on('unhandledRejection', err => {
console.error('unhandledRejection', err);
process.exit(1);
});Performance. Zod validation is fast (microseconds for typical bodies). Don't micro-optimize before you measure.
Security. Hide internal codes in 500 responses. requestId lets users quote it; the actual exception lives only in your logs.
PATCH /notes/:id. All fields optional.req.query.limit as a number, default 20, max 100.ApiError(409, ...) when inserting a duplicate email.requestId to every log line and error response.ApiErrors.Beginner. What status code for a failed validation? 422 (Unprocessable Entity) is canonical; 400 is widely accepted.
Senior. How do you distinguish operational from programmer errors? Operational: known failure modes (DB down, validation failed, third party 500). Programmer: bugs (undefined property, missing await). Operational gets handled; programmer gets logged and the process restarted.
Validate every incoming request with Zod. Make validation a reusable middleware. Throw typed ApiErrors from your service layer. Catch all errors in one error middleware that returns a consistent JSON shape with a request ID. Distinguish operational errors (return 4xx) from programmer errors (log + crash + restart). With this in place your API responds cleanly to every possible .
safeParse returns success/issues.ApiError carries the status code through to the response.requestId in every error response?z.coerce.number() do?