Middleware and input

Validate and normalize input

Validate types, ranges, shapes, and business rules, then pass a clean value into the domain layer.

Parsing answers one question: can I read this? Validation answers a different one: is this allowed here? And output encoding answers a third: is this safe to put in HTML? Three questions, three places. Mixing them is where input bugs come from.

For a note, the rules are small. The title is required, trimmed, and at most 120 characters. The body is optional and at most 10,000 characters. Both are text, and text may contain < and >.

Validate at the boundary

I write validation as a plain function that takes the raw body and returns either a clean value or a list of errors:

export function validateNote(input) {
  const errors = []
  const title = typeof input?.title === 'string' ? input.title.trim() : ''
  const body = typeof input?.body === 'string' ? input.body : ''

  if (title.length === 0) errors.push('title is required')
  if (title.length > 120) errors.push('title must be 120 characters or fewer')
  if (body.length > 10000) errors.push('body must be 10000 characters or fewer')

  return errors.length ? { errors } : { value: { title, body } }
}

Notice the typeof checks. A JSON client can send { "title": ["a", "b"] } or { "title": 42 }. Checking the type first means the rest of the function only ever sees strings.

The route uses it and answers 400 with the list when something is wrong:

apiRouter.post('/notes', (req, res) => {
  const result = validateNote(req.body)
  if (result.errors) {
    return res.status(400).json({ errors: result.errors })
  }
  const note = createNote(store, result.value)
  res.status(201).json(note)
})

The domain function receives result.value, never req.body. Everything past this line can trust its input.

If you prefer a library, express-validator does the same job with a chain like body('title').trim().isLength({ min: 1, max: 120 }). The shape of the idea doesn’t change.

Normalize only by product rules

Trimming the title is a normalization. We do it because a title with leading spaces is never what the user meant. That’s a product decision, and it’s the only one here.

What I don’t do is strip HTML from the text. A note that says use <div> not <table> is a valid note. Destroying it at input time to protect a page that may render it is the wrong layer.

Encode at the output

The place that puts text into HTML is the place that escapes it. Every template engine does this by default. With plain template strings, do it yourself:

export function escapeHtml(text) {
  return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}

The JSON API returns the stored text as is, because JSON is not HTML. A React frontend escapes it again on its side. Each renderer protects its own context.

Test with a table

I keep a small table of inputs and the stored value I expect:

Input titleResult
" Buy milk "stored as "Buy milk"
""400, title is required
121 characters400, too long
"<b>bold</b>"stored unchanged, page shows &lt;b&gt;
["a", "b"]400, title is required
"Caffè ☕"stored unchanged

Each row is one supertest case. When all rows pass, you know parsing, validation, and encoding each did their own job and nobody else’s.

Lesson completed