# Request Validation

## 1. Validate at the Boundary, Trust Everywhere Else

Every request entering an API crosses a trust boundary: everything before it is attacker-controlled input, everything after it should be able to assume clean, well-typed data. Validating at the boundary — before the request reaches business logic — means the rest of the codebase never has to defensively re-check "is this actually a number" ten call-frames deep; it can simply trust its own types, because the boundary already enforced them.

```ts
// WITHOUT boundary validation: business logic has to defend against garbage everywhere
function createOrder(body: any) {
  if (typeof body.customerId !== "number") throw new Error("bad customerId"); // defensive check, buried deep
  // ... twenty lines later, another defensive check for another field ...
}

// WITH boundary validation: business logic trusts its input completely
function createOrder(input: CreateOrderInput) { // input is ALREADY validated by the time it gets here
  // no defensive checks needed -- the type is a genuine guarantee, not a hope
}
```

## 2. Schema Definition + Parse, Not Scattered `if` Checks

Real validation libraries (Zod, Joi, Yup, class-validator) share a common pattern: declare the expected shape once as a schema, then call one `.parse()`/`.validate()` against it, rather than hand-writing a chain of `if` statements per field:

```ts
import { z } from "zod";

const CreateOrderInput = z.object({
  customerId: z.number().int().positive(),
  items: z.array(z.object({
    productId: z.number().int().positive(),
    quantity: z.number().int().min(1).max(100),
  })).min(1),
  couponCode: z.string().optional(),
});

type CreateOrderInput = z.infer<typeof CreateOrderInput>; // the TS TYPE is derived FROM the schema -- one source of truth
```

```ts
app.post("/orders", (req, res) => {
  const result = CreateOrderInput.safeParse(req.body);
  if (!result.success) {
    return res.status(422).json(formatZodErrors(result.error)); // theory: backend/api-design/09-error_response_design
  }
  const input = result.data; // fully typed, fully validated -- createOrder trusts it completely
  createOrder(input);
});
```

The schema is simultaneously runtime validation logic AND (via type inference) the compile-time TypeScript type — eliminating the class of bug where a hand-written `interface` and its hand-written `if`-check validation silently drift apart over time.

## 3. `400` vs. `422` — Malformed vs. Semantically Invalid

Matching `backend/api-design/09-error_response_design`'s status-code discipline: a request the server can't even parse (`Content-Type: application/json` but the body isn't valid JSON at all) is `400`; a request that parses fine but fails schema rules (`quantity: -5`, `customerId: "not-a-number"`) is `422`:

```ts
app.use(express.json()); // throws a SyntaxError -> caught by error middleware -> 400, BEFORE your handler even runs

app.post("/orders", (req, res) => {
  const result = CreateOrderInput.safeParse(req.body); // req.body IS valid JSON by this point -- 422 territory now
  if (!result.success) return res.status(422).json(...);
});
```

If these two are collapsed into a single `400` for everything, a client can no longer distinguish "I sent broken JSON, a client bug" from "I sent well-formed JSON that fails your business rules" without parsing the response body — exactly the discipline `error_response_design` covers in depth.

![Raw input passes syntactic schema validation (400 on failure) and then semantic checks that need data or context (422 on failure) before becoming a trusted DTO](diagrams/01-validation-stages.svg)

## 4. Sanitization Is a Distinct Step From Validation

**Validation** asks "does this input conform to the expected shape/rules" (reject if not). **Sanitization** *transforms* input into a safe/canonical form (trim whitespace, strip disallowed HTML, normalize casing) — the two are often run together but answer different questions:

```ts
const CreateCommentInput = z.object({
  body: z.string()
    .trim()                                    // SANITIZE: remove leading/trailing whitespace
    .min(1, "comment cannot be empty")          // VALIDATE: reject if empty after trimming
    .transform((s) => sanitizeHtml(s, { allowedTags: ["b", "i", "a"] })), // SANITIZE: strip dangerous markup
});
```

Validation alone doesn't protect against injection-style attacks (a syntactically valid string can still contain a `<script>` tag); sanitization alone doesn't enforce business rules (a perfectly sanitized empty string is still not a valid comment). Both steps matter, and conflating "I validated it" with "it's now safe to render/store" is a common source of stored-XSS-style bugs.

## 5. Strict Mode: Rejecting Unknown Fields

By default, most validators only check that expected fields match their rules — they silently *ignore* extra fields the client sent that aren't in the schema. **Strict mode** rejects the request outright if it contains any field not declared in the schema:

```ts
const StrictCreateOrderInput = CreateOrderInput.strict(); // Zod: unknown keys now cause a validation FAILURE, not silent drop
```

Strict mode is a genuine security control, not just tidiness — it's a second line of defense against mass assignment (`backend/api-design/10-api_security` section 4): even if a handler later forgets to apply an explicit writable-field allowlist, strict-mode rejection at the validation boundary stops a client from smuggling in fields like `role` or `isAdmin` that were never declared as acceptable input in the first place.

## Common Pitfalls

1. **Validating deep inside business logic instead of at the boundary.** Defeats the entire "trust your own types past this point" guarantee (section 1) — every layer ends up re-checking the same things defensively.
2. **Hand-writing both a TS `interface` and separate `if`-based validation.** The two inevitably drift — a field added to the interface but not the validation (or vice versa) is a silent gap, exactly what schema-derived types (section 2) eliminate.
3. **Collapsing `400` and `422`.** Forces clients to inspect the response body to distinguish a client-side JSON bug from a legitimate business-rule rejection.
4. **Treating validation and sanitization as the same step.** A validated-but-unsanitized string can still carry an injection payload; conflating the two is how stored-XSS bugs slip through code review that only checked "is this field the right shape."
5. **Silently accepting unknown fields by default.** Without strict mode, a client can send extra fields that a careless downstream handler (missing the allowlist from `10-api_security` section 4) might accidentally persist.

## Quick Reference

```
Validate AT THE BOUNDARY -- business logic trusts its input completely past this point
Schema + .parse()          -- one source of truth for both runtime checks AND the TS type (schema-first)
400                          -- malformed, unparseable request (bad JSON)
422                           -- well-formed request, fails semantic/business rules
Validation                     -- does this conform? (reject if not)
Sanitization                    -- transform into a safe/canonical form (trim, strip HTML, normalize)
Strict mode                      -- reject UNKNOWN fields -- a real defense-in-depth layer against mass assignment
```
