Agent skill
validation-boundary
Validate at the boundary with Zod schemas and branded types. Business functions trust validated input.
Install this agent skill to your Project
npx add-skill https://github.com/jagreehal/jagreehal-claude-skills/tree/main/skills/validation-boundary
SKILL.md
Validation at the Boundary
Core Principle
Validation is a boundary concern. Check passports once at the border, not at every street corner.
External Input (HTTP, CLI, Queue) <- untrusted
|
v
Boundary Layer (validate with Zod) <- reject bad data here
|
v
Business Functions fn(args, deps) <- args ALREADY valid by contract
Parse, Don't Validate
Validation checks data and returns true/false. Parsing transforms data into a new, richer type.
// Validation mindset: "Is this email valid?"
function isValidEmail(s: string): boolean { ... }
// Parsing mindset: "Give me an Email, or fail"
function parseEmail(s: string): Email { ... }
With parsing, you have an Email type that CANNOT be invalid by construction.
Required Behaviors
1. Define Schemas with Zod
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
2. Use Branded Types for Stronger Guarantees
const EmailSchema = z.string().email().brand<'Email'>();
const UserIdSchema = z.string().uuid().brand<'UserId'>();
type Email = z.infer<typeof EmailSchema>; // string & { __brand: 'Email' }
type UserId = z.infer<typeof UserIdSchema>; // string & { __brand: 'UserId' }
// Now TypeScript prevents accidental raw strings
function sendEmail(to: Email, subject: string) { ... }
sendEmail("alice@example.com", "Hello"); // ERROR: string not assignable to Email
sendEmail(EmailSchema.parse("alice@example.com"), "Hello"); // OK
When to Use Branded Types vs Plain Types
| Use Branded Types | Use Plain Types |
|---|---|
IDs that look alike (userId, orderId) |
Internal-only types |
| Security-sensitive values (tokens, keys) | Simple strings with no confusion risk |
| Values that MUST go through validation | Prototyping / early development |
| Cross-boundary data | Types only used in one function |
Rule of thumb: If mixing up two string parameters would cause a bug, brand them.
3. Validate at HTTP/Queue/CLI Boundaries
app.post('/users', async (req, res) => {
// 1. Validate at the boundary
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json(formatZodError(parsed.error));
}
// 2. Call business function with valid, typed data
const user = await userService.createUser(parsed.data);
return res.status(201).json(user);
});
4. Business Functions Trust the Contract
NO validation inside business functions. They trust args are already valid:
// CORRECT - No validation, trust the contract
async function createUser(
args: CreateUserInput, // Already validated!
deps: CreateUserDeps
): Promise<User> {
const user = { id: crypto.randomUUID(), ...args };
await deps.db.saveUser(user);
return user;
}
// WRONG - Validation mixed with business logic
async function createUser(args: { name: string; email: string }, deps) {
if (!args.name || args.name.length < 2) {
throw new Error('Name must be at least 2 characters'); // DON'T DO THIS
}
// ...
}
5. Standardize Validation Error Responses
type ValidationErrorResponse = {
error: 'VALIDATION_FAILED';
message: string;
issues: Array<{
path: string;
message: string;
code: string;
}>;
};
function formatZodError(error: z.ZodError): ValidationErrorResponse {
return {
error: 'VALIDATION_FAILED',
message: 'Request validation failed',
issues: error.issues.map(issue => ({
path: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
};
}
Two Layers of Validation
| Type | Where | What | Tool |
|---|---|---|---|
| Schema Validation | Boundary | Shape, types, format, ranges | Zod |
| Domain Validation | Business function | Business rules (email exists, has permission) | Database lookups |
// Schema validation (boundary)
const TransferSchema = z.object({
fromAccount: z.string().uuid(),
toAccount: z.string().uuid(),
amount: z.number().positive(),
});
// Domain validation (business function)
async function validateTransfer(args: TransferInput, deps: TransferDeps) {
const account = await deps.db.getAccount(args.fromAccount);
if (account.balance < args.amount) {
return err('INSUFFICIENT_FUNDS'); // Business rule, not schema
}
// ...
}
Common Patterns
Coercion (Query Parameters)
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// "?page=2&limit=50" -> { page: 2, limit: 50 }
Partial Updates (PATCH)
const UpdateUserSchema = z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional(),
});
Transforms
const CreatePostSchema = z.object({
title: z.string().transform(s => s.trim()),
slug: z.string().transform(s => s.toLowerCase().replace(/\s+/g, '-')),
});
Express Middleware
function validateBody<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json(formatZodError(result.error));
}
req.body = result.data;
next();
};
}
app.post('/users', validateBody(CreateUserSchema), async (req, res) => {
const user = await userService.createUser(req.body);
res.status(201).json(user);
});
Quick Reference
| Question | Answer |
|---|---|
| Where validate shape/format? | Boundary (Zod schema) |
| Where validate business rules? | Business function |
| Should fn(args, deps) validate args? | NO. Trust the contract |
| Error for invalid input? | HTTP 400 (client error) |
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
skill-authoring
Use when creating, editing, or reviewing skills. Covers discovery optimization, structure patterns, testing approaches, and format decisions.
testing-strategy
Test pyramid approach with unit, integration, and load tests. DI enables testability. Use vitest-mock-extended for typed mocks.
api-design
Build production-ready HTTP APIs with clean handlers, consistent error envelopes, health checks, CORS, and operational excellence.
branch-completion
Use when implementation is complete and tests pass. Guides completion by presenting structured options for merge, PR, or cleanup.
resilience
Add retry, timeout, and circuit breaker patterns at the workflow level. Business functions stay clean.
session-continuity
Persistent task workflow with state machine. Every message MUST announce state. Uses .claude/ files for multi-session continuity. Never use TodoWrite. Never auto-advance tasks.
Didn't find tool you were looking for?