Data Validation (Parse, Don't Validate)
Purpose
Validate untrusted input exactly once, at the system boundary, and convert it into a typed value that downstream code is statically guaranteed is valid. Stop passing raw/unparsed data inward and re-checking it everywhere.
Universal — the "parse, don't validate" principle (validate at the boundary, return a typed parsed value) applies to any typed language; only the validation library differs.
Procedure
Identify every trust boundary
- HTTP request body / query / params / headers
- Queue / event payloads
- External API responses (don't trust them either)
- File contents, env vars
Define a schema per boundary input
- Co-locate with the endpoint or in a shared schema module
- The schema IS the documentation of what's accepted
Parse, don't validate — return a typed value, not a boolean
const data = Schema.parse(raw)→datais now a typed, guaranteed-valid object- Anti-pattern:
if (isValid(raw)) { use raw as any }— downstream still sees untyped data - Use
safeParseat boundaries to convert failures into a 400 response, not a thrown 500
Validate at the boundary ONLY — trust inward
- Once parsed, inner functions take the typed value and never re-validate
- Re-validation everywhere = noise + drift; the type system carries the guarantee
Coerce and normalize during parse
- Trim strings, coerce numeric query params, normalize emails/dates
- Output of parse should be canonical form, ready to use
5b. Cap size + bound dangerous types at the boundary
- Payload size limit at the HTTP layer (e.g. 1MB body cap by default; raise per-endpoint when justified) — a 10GB JSON request will OOM the server before the schema parser is reached
- String length / array length caps in the schema —
Schema.string().max(N),.array().max(N)— prevent ReDoS / pathological allocations - Numbers: distinguish int vs float;
BigIntfor IDs / counters that may exceedNumber.MAX_SAFE_INTEGER; rejectNaN/Infinity - Dates / timezones: parse into a canonical UTC
Date/ instant; reject ambiguous local time without zone — date-string handling is the #1 silent corruption source - Regex: avoid user-supplied regex or unbounded
*?patterns (ReDoS); use a regex library with timeout or pre-validated patterns
- Validate (validation loop)
- Send malformed input to each boundary; verify a clean 400 (not a 500 or silent acceptance)
- If invalid data reaches business logic / DB → the boundary schema is incomplete; fix and re-test
Anti-patterns
| ❌ Anti-pattern | ✅ Correct |
|---|---|
if (isValid(x)) { use x } (boolean check) |
const parsed = Schema.parse(x) (typed value) |
| Re-validating the same data in 5 inner functions | Parse once at boundary, trust the type inward |
| Trusting external API responses without parsing | Parse external responses too — they're untrusted |
body as RequestType (type assertion, no runtime check) |
Runtime parse that produces the type |
| Throwing 500 on bad input | safeParse → 400 with field errors |
| No request-body size cap (10GB JSON OOMs the server) | Body-size limit at HTTP layer + .max() in schema |
| Unbounded string / array fields | .max(N) in the schema |
| Storing user input as a local-time date with no zone | Parse to canonical UTC; reject ambiguous local-time strings |
| User-supplied or unbounded regex | Pre-validated patterns + execution timeout |
Completion Criteria
- Every trust boundary has a parse step
- Parse returns typed values (no
asassertions on raw input) - Malformed input returns 400 (verified), never 500 or silent acceptance
- No re-validation of already-parsed data in inner layers
Output
- Schema modules: one per boundary input, shared where reused
- Boundary parse code:
safeParse→ 400 mapping - Commit format:
feat(validation): parse <endpoint> input at boundary
Implementation
TypeScript + NestJS (default)
- Zod schemas +
safeParseat the controller boundary, OR NestJSclass-validatorDTOs withValidationPipe - Zod for shared client/server schemas (pairs with frontend-toolkit
form-ux) - Map
ZodError→ RFC 9457 400 response in a global filter
Other stacks
- Python / FastAPI: Pydantic v2 models (parsing is built into the framework — request body → typed model)
- Go:
go-playground/validatoron structs; or parse into typed structs explicitly - Universal: "parse don't validate" is a principle (validate at boundary → typed value), implementable in any typed language
Related skills
api-contract— the contract's request schema is the validation schemabackend-security-audit— input validation is the first injection defenseauthentication— validate token claims as untrusted input
Reference
- Key insight encoded: Validate once at the trust boundary and return a typed parsed value (not a boolean) so downstream code is statically guaranteed valid.
- Caveat: King's essay is Haskell-flavored — the principle is universal but the examples are FP. Pair with Zod docs for the concrete TypeScript landing.