# Data Validation

> Validate untrusted input once at the trust boundary and return a typed parsed value (parse, don't validate). Use when adding an endpoint, accepting external input, or when invalid data leaks past the boundary into business logic. Not for defining the API contract/schema itself (use api-contract) or downstream business-rule logic — parse only at the trust boundary.

- Skill: `jaykim88/data-validation` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/data-validation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/data-validation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/data-validation

---


# 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

1. **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

2. **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

3. **Parse, don't validate — return a typed value, not a boolean**
   - `const data = Schema.parse(raw)` → `data` is now a typed, guaranteed-valid object
   - Anti-pattern: `if (isValid(raw)) { use raw as any }` — downstream still sees untyped data
   - Use `safeParse` at boundaries to convert failures into a 400 response, not a thrown 500

4. **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

5. **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; `BigInt` for IDs / counters that may exceed `Number.MAX_SAFE_INTEGER`; reject `NaN` / `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

6. **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 `as` assertions 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 + `safeParse` at the controller boundary, OR NestJS `class-validator` DTOs with `ValidationPipe`
- 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/validator` on 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 schema
- `backend-security-audit` — input validation is the first injection defense
- `authentication` — 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.

