Beginner · Building the backend · topic 12 of 34 · 30 min
Validating input and handling errors on the server
Explanation
Never trust the browser
Your React form may stop an empty title. But the API is just a URL. Anyone can call it with a tool like curl or Postman, or change the request in the browser's developer tools. Browser checks are for comfort. Server checks are for safety.
curl -X POST https://notes.example.com/api/notes \
-H 'Content-Type: application/json' \
-b 'sid=...' \
-d '{"title": "", "body": 12345, "pinned": "yes"}'Validate and normalise
Validation means checking that the input follows the rules and rejecting it if not. Normalising means cleaning the input into one standard form before saving it: trimming spaces, lower-casing an email, filling in defaults. Normalise first where it matters (trim before checking the title length), then validate.
| Field | Normalise | Rule |
|---|---|---|
| title | trim spaces at both ends | must be text, 1 to 100 characters after trimming |
| body | missing becomes "" | must be text, at most 5000 characters |
| pinned | missing becomes false | must be true or false (not "yes", not 1) |
| email (signup) | trim and lower-case | must look like an email; unique in users |
| password (signup) | do not change it at all | at least 8 characters (more rules in module 6) |
import { z } from 'zod';
const NoteInput = z.object({
title: z.string().trim().min(1, 'Title is required').max(100),
body: z.string().max(5000).default(''),
pinned: z.boolean().default(false),
});
const result = NoteInput.safeParse(req.body);
if (!result.success) {
throw new AppError(400, 'VALIDATION_ERROR', result.error.issues[0].message);
}
const input = result.data; // trimmed title, defaults filled inA schema library such as Zod lets you write the rules as a description instead of many if statements. You can write the checks by hand too (you will in the task below), and it is good to do it once to understand what the library does for you.
One error shape and the right status code
| Status | When | Example code |
|---|---|---|
| 400 Bad Request | The input breaks a rule, or the JSON is broken | VALIDATION_ERROR, INVALID_JSON |
| 401 Unauthorized | Not logged in, or wrong email or password | UNAUTHORIZED |
| 404 Not Found | The note does not exist or is not yours | NOT_FOUND |
| 409 Conflict | The request clashes with existing data, such as an email already registered | EMAIL_TAKEN |
| 500 Internal Server Error | A bug or a failure on our side | INTERNAL_ERROR |
A central error handler
Instead of building error responses in every route, routes and services just throw an AppError (an error with a status and a code). One error-handling middleware at the end turns any error into the contract shape. Express knows it is an error handler because it takes four arguments: (err, req, res, next).
class AppError extends Error {
constructor(status, code, message) {
super(message);
this.status = status;
this.code = code;
}
}
// Added AFTER all routes
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.status).json({ error: { code: err.code, message: err.message } });
}
// Unknown error: log everything for us, show nothing private to the user
console.error('Request failed', { method: req.method, path: req.path, err });
res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'Something went wrong. Please try again.' } });
});Watch the lesson
Validating input and handling errors on the server · 1/6
The browser can be skipped. Anyone can call your API with curl. So the server checks everything again.
Prefer a video by a teacher? Find free videos on “express error handling middleware input validation zod tutorial” on YouTube ↗
In real life: A UPI payment that fails safely
When you send money with a UPI app and type an amount like 0 or a wrong UPI id, the app shows a clear message such as "Enter a valid amount" or "This UPI id is not valid". Those checks run in the app, and they are run again on the server, because a modified app could send anything. If something breaks deep inside the bank's system, you never see a stack trace: you see "Payment failed, any money debited will be refunded", while the full details go into the server logs for the support team.
Practice (0/3 done)
Write normaliseSignup(input) for POST /api/auth/signup. Return { name, email, password } in that order: name with spaces trimmed from both ends; email trimmed and lower-cased; password exactly as given (never change a password). A missing name or email becomes "".
Write validateNoteInput(input). On success return { ok: true, value: { title, body, pinned } }; on failure return { ok: false, error: { code: "VALIDATION_ERROR", message } } with the FIRST problem found, checked in this order and with these exact messages: input is not a plain object (null or an array also fail): "Body must be a JSON object". title is missing: "Title is required". title is not a string: "Title must be text". title is empty after trimming: "Title is required". title longer than 100 after trimming: "Title must be at most 100 characters". body present but not a string: "Body must be text". body longer than 5000: "Body must be at most 5000 characters". pinned present but not a boolean: "Pinned must be true or false". The value has the trimmed title, body "" when missing and pinned false when missing.
Write the core of the central error handler: toErrorResponse(err) returns { status, body } with body = { error: { code, message } }. Rules: 1) err is an AppError (class given): use its status, code and message. 2) err.type is "entity.parse.failed" (broken JSON): 400, "INVALID_JSON", "The request body is not valid JSON". 3) err.code is "SQLITE_CONSTRAINT_UNIQUE" (the email already exists): 409, "EMAIL_TAKEN", "This email is already registered". 4) Anything else: 500, "INTERNAL_ERROR", "Something went wrong. Please try again." Never put err.message or err.stack of an unknown error in the body.
Quick quiz
Read more (free)
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.