Input Validation — Trim at the Boundary
Input Validation — Trim at the Boundary
Untrusted input arrives at the edges of the system. HTTP body, query, headers, environment variables, external API responses, file contents. Letting that input flow into the inner code blurs both type and meaning, and security incidents often start exactly at the edges. This article covers boundary validation, zod and its siblings, and how to split validation between client and server.
1. Boundary validation
The core principle — untrusted input is validated once at the system boundary and converted into a trusted type. The inner code then assumes that trusted type.
The boundaries:
- HTTP handlers — body, query, path, headers.
- Environment variables — once at startup.
- Right after JSON parsing —
unknown→ typed. - External API responses — the response schema can change.
- Message queue consumers — old message shapes are possible.
The result of validation is a value with guaranteed type and meaning; the result of failure is a clear error response.
2. zod
A TypeScript validation library started in 2020 by Colin McDonnell. The core idea: "the schema definition is the type."
import { z } from 'zod';
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
});
type User = z.infer<typeof UserSchema>;
const result = UserSchema.safeParse(input);
if (!result.success) {
// structured messages in result.error
} else {
const user = result.data; // type: User
}
- Composable schemas (
z.union,z.intersection,z.discriminatedUnion). - Transformations (
z.transform) — validate plus normalize. - Async validation (
z.refineasync).
3. Other tools in the TypeScript world
| Library | Origin | Model | Notes |
|---|---|---|---|
| zod | 2020 | Method chaining | Strong type inference; most widely used |
| valibot | 2023 | Functional pipe | Small bundle |
| ArkType | 2023 | Type expressions | Strong inference; learning curve |
| yup | 2014 | Method chaining | Older standard |
| joi | 2012 | Builder | Node-friendly; weak inference |
| superstruct | 2018 | Functional | Lightweight |
valibot is reported to tree-shake better thanks to functional composition (pipe(string(), email())). ArkType expresses schemas with the type system itself — strong inference but a steeper learning curve.
4. Other languages
Python:
- Pydantic (2017, Samuel Colvin) — class-based data validation. v2 (2023) rewrote the core in Rust for performance. Foundation of FastAPI.
- marshmallow (2014) — schema and serialization. The standard of the Flask era.
- attrs / dataclasses + validation — standard-library oriented.
JVM:
- Bean Validation (JSR 380) — annotations like
@NotNull,@Size,@Email. - Spring
@Valid+Validated— Bean Validation integration.
Rust — serde + validator (separates serialization and validation).
The pattern is the same regardless of language. Untrusted shape → validation → typed value.
5. Per-location application
HTTP body:
// Next.js Route Handler
export async function POST(req: Request) {
const json = await req.json();
const parsed = UserSchema.safeParse(json);
if (!parsed.success) return Response.json({ errors: parsed.error.issues }, { status: 400 });
// parsed.data is validated
}
Query / Path — query parameters are all strings. If you need numbers or booleans, convert explicitly:
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
Headers — Authorization, X-Api-Key, and similar. Specify format and length.
Environment variables — validate once at startup so misconfiguration fails fast:
const Env = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'staging', 'production']),
});
export const env = Env.parse(process.env);
It must pass at build time or startup for the process to boot.
Right after JSON parsing — JSON.parse(...) returns any. Do not cast (as User); validate.
External API responses — if the API changes silently, the old code breaks. Validating the response by schema catches the change at ingestion.
6. Client / server split
Principles:
- Server validation always — the deciding factor for security.
- Client validation is for UX — quick feedback, fewer server calls.
- Shared schema — both client and server commonly import the same zod schema.
// shared/user-schema.ts
export const UserSchema = z.object({...});
This pattern is most natural in a monorepo or single codebase.
Form libraries:
- React Hook Form +
@hookform/resolvers/zod—useForm({ resolver: zodResolver(schema) }). - TanStack Form — its own validation plus a zod adapter.
- Conform — server-action friendly forms.
Server actions (Next.js) need the same validation. They can be invoked from non-client environments, so form-library validation alone is insufficient.
7. Error response shape
Validation failures should follow a consistent shape. Refer to a standard like RFC 9457 (Problem Details for HTTP APIs, 2023):
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"errors": [
{ "path": "email", "message": "Invalid email" }
]
}
8. Safe defaults and evolution
Partial updates — for PATCH, use .partial() or .deepPartial() to make every field optional:
const UserUpdate = UserSchema.partial();
Strict mode — z.object(...) strips unknown keys by default. To reject unknown keys use .strict(). To preserve them use .passthrough().
Schema evolution — when older shapes still exist in storage, lift them with a union or transform:
const Legacy = z.object({ name: z.string() });
const Current = z.object({ firstName: z.string(), lastName: z.string() });
const Either = z.union([Current, Legacy.transform(({ name }) => ({ firstName: name, lastName: '' }))]);
9. Common pitfalls
Bypassing validation with as — TypeScript's as User means nothing at runtime. Trust the type only after validation.
Partial-validation trap — validating one object but leaving nested objects alone scatters trust by depth. Use a deep schema.
Mixing transform and validation — transform produces a normalized output whose type differs from input. Separate input and output schemas for the API.
Error-message exposure — internal details (table names, SQL) should not leak to users.
Too-lenient schemas — z.unknown() everywhere defeats validation.
Ignoring external API drift — when a response changes, ingestion fails, but that failure must not leak into the user response. Separate ingestion and presentation.
Missing i18n messages — zod's default English messages going straight to users feels off. Map the messages.
Bundle size — a heavy validation library on the client affects initial load. Tree-shake or split runtimes.
Closing thoughts
Validate untrusted input once at the system boundary so the inner code can assume a trusted type — that simple principle improves security, type safety, and debugging together. Client validation is UX; server validation is security. A shared schema keeps the two definitions from drifting.
Next
- password-hashing
- headers-and-cors
We refer to zod official, valibot official, ArkType official, Pydantic official, Bean Validation spec, RFC 9457 Problem Details, and the OWASP Input Validation Cheat Sheet.