Skip to content

Beginner · Building the backend · topic 12 of 34 · 30 min

Validating input and handling errors on the server

Explanation

The server must check every request itself, because anyone can skip your website and send any data straight to the API. Validate and clean the input (trim spaces, lower-case emails, check types and lengths), and answer every problem in one error shape with the right status code. One central error handler logs the details for you and shows users only a safe message.

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.

bash
curl -X POST https://notes.example.com/api/notes \
  -H 'Content-Type: application/json' \
  -b 'sid=...' \
  -d '{"title": "", "body": 12345, "pinned": "yes"}'
One command skips every check in your frontend. The server must reject this.
Think of it like this: A railway ticket checker does not trust the ticket because "the booking website checks everything". He checks your ticket and ID on the train himself. The website check makes booking smooth; the checker on the train keeps the journey safe. Your server is the ticket checker.

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.

FieldNormaliseRule
titletrim spaces at both endsmust be text, 1 to 100 characters after trimming
bodymissing becomes ""must be text, at most 5000 characters
pinnedmissing becomes falsemust be true or false (not "yes", not 1)
email (signup)trim and lower-casemust look like an email; unique in users
password (signup)do not change it at allat least 8 characters (more rules in module 6)
javascript
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 in
Zod is a popular schema library: you describe the shape once, and it validates and normalises in one step.

A 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

StatusWhenExample code
400 Bad RequestThe input breaks a rule, or the JSON is brokenVALIDATION_ERROR, INVALID_JSON
401 UnauthorizedNot logged in, or wrong email or passwordUNAUTHORIZED
404 Not FoundThe note does not exist or is not yoursNOT_FOUND
409 ConflictThe request clashes with existing data, such as an email already registeredEMAIL_TAKEN
500 Internal Server ErrorA bug or a failure on our sideINTERNAL_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).

javascript
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.' } });
});
Runs on the server. Known errors keep their status; unknown ones become a safe 500.
Tip: express.json() rejects a broken JSON body with an error whose type is "entity.parse.failed" and status 400. Handle it in the error handler and answer 400 INVALID_JSON instead of a 500.
Careful: Never send stack traces, SQL text or raw database errors to users. They show file paths, table names and library versions, which help attackers. Log the full details on the server (with the time and a request id) and send the user a short, safe message.

Watch the lesson

Validating input and handling errors on the server · 1/6

Browser checks: comfort Server checks: safety

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)

Task 1 · Code · Javascript

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 "".

Task 2 · Code · Javascript

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.

Task 3 · Code · Javascript

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

Question 1

1.The React form already blocks empty titles. Does the server still need to check?

Question 2

2.Which input should be lower-cased before saving?

Question 3

3.Signup with an email that is already registered should answer:

Question 4

4.How does Express recognise error-handling middleware?

Question 5

5.A database call fails with a long error full of table names. What should the user see?

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.

← Previous: Route handlers that follow the contract