Validation
itty-spec automatically validates all incoming requests against your contract schemas before your handlers run. This ensures type safety and data integrity.
How Validation Works
Validation happens automatically in the middleware chain:
Path Parameter Validation
Path parameters are extracted from the URL and validated against your schema.
Automatic Extraction
When you use path patterns like /users/:id, parameters are automatically extracted as strings:
const contract = createContract({
getUser: {
path: "/users/:id", // id is extracted as string
method: "GET",
responses: { /* ... */ },
},
});
// In handler:
const { id } = request.validatedParams; // { id: string }Explicit Validation
For stricter validation, provide a pathParams schema:
const contract = createContract({
getUser: {
path: "/users/:id",
method: "GET",
pathParams: z.object({
id: z.string().uuid(), // Validates UUID format
}),
responses: { /* ... */ },
},
});
// Validation fails if id is not a valid UUID
// GET /users/not-a-uuid → 400 Bad RequestValidation Errors
Invalid path parameters return a 400 error:
{
"error": "Validation failed",
"details": [
{
"path": ["id"],
"message": "Invalid uuid"
}
]
}Query Parameter Validation
Query parameters are parsed from the URL query string and validated.
Basic Validation
const contract = createContract({
searchUsers: {
path: "/users",
method: "GET",
query: z.object({
q: z.string().min(1), // Required
limit: z.number().min(1).max(100).default(10),
offset: z.number().optional(),
}),
responses: { /* ... */ },
},
});Type Coercion
Query parameters are strings in URLs, so you may need to transform them:
query: z.object({
page: z.string()
.transform(Number)
.pipe(z.number().min(1)),
limit: z.string()
.transform(Number)
.pipe(z.number().min(1).max(100)),
active: z.enum(['true', 'false'])
.transform(val => val === 'true'),
})Array Parameters
Handle array query parameters:
// ?tags=tag1&tags=tag2
query: z.object({
tags: z.array(z.string()).optional(),
ids: z.array(z.string().uuid()),
})Missing Parameters
- Required parameters without defaults: validation fails
- Optional parameters: set to
undefined - Parameters with defaults: use default value
Header Validation
Headers are normalized to lowercase and validated against your schema.
Basic Validation
const contract = createContract({
createUser: {
path: "/users",
method: "POST",
headers: z.object({
authorization: z.string(),
"content-type": z.literal("application/json"),
"x-api-key": z.string(),
}),
responses: { /* ... */ },
},
});Header Normalization
All header keys are normalized to lowercase:
// Schema definition
headers: z.object({
"Authorization": z.string(), // Capital A
"X-API-Key": z.string(), // Mixed case
})
// Runtime access (always lowercase)
request.validatedHeaders.get("authorization"); // ✅ Works
request.validatedHeaders.get("x-api-key"); // ✅ WorksComma-Separated Values
Some headers (like Accept) may contain comma-separated values. itty-spec handles this automatically:
headers: z.object({
accept: z.union([
z.literal("application/json"),
z.literal("text/html"),
]),
})
// Request: Accept: application/json, text/html
// Validation tries each value until one matchesBody Validation
Request bodies are validated based on the Content-Type header.
Single Content Type
const contract = createContract({
createUser: {
path: "/users",
method: "POST",
requests: {
"application/json": {
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
},
},
responses: { /* ... */ },
},
});Multiple Content Types
When multiple content types are defined, validation uses the request's Content-Type header:
requests: {
"application/json": {
body: z.object({ name: z.string() }),
},
"application/xml": {
body: z.string(), // XML as string
},
}
// Request with Content-Type: application/json
// → Validates against JSON schema
// Request with Content-Type: application/xml
// → Validates against XML schemaMissing Content-Type
If Content-Type is required but missing, validation fails with 400:
{
"error": "Content-Type header is required"
}Unsupported Content-Type
If the Content-Type doesn't match any defined schema:
{
"error": "Unsupported Content-Type: text/plain. Supported types: application/json, application/xml"
}Empty Body
If no body is sent:
- Request body is set to
{}(empty object) - Validation passes if body schema is optional or has defaults
Validation Error Handling
When validation fails, itty-spec automatically returns a 400 Bad Request response.
Error Response Format
{
"error": "Validation failed",
"details": [
{
"path": ["email"],
"message": "Invalid email"
},
{
"path": ["age"],
"message": "Expected number, received string"
}
]
}Custom Error Handling
You can customize error handling in middleware:
const router = createRouter({
contract,
handlers,
before: [
async (request) => {
try {
// Validation happens automatically
} catch (error) {
if (error instanceof Error && 'issues' in error) {
// Custom validation error handling
return new Response(
JSON.stringify({
customError: "Validation failed",
issues: error.issues,
}),
{ status: 400 }
);
}
throw error;
}
},
],
});Custom Validation Patterns
Conditional Validation
Use Zod's refinement for conditional validation:
const schema = z.object({
type: z.enum(['email', 'phone']),
value: z.string(),
}).refine(
(data) => {
if (data.type === 'email') {
return z.string().email().safeParse(data.value).success;
}
return /^\d{10}$/.test(data.value);
},
{ message: "Invalid value for type" }
);Async Validation
For database lookups or external API calls:
const schema = z.object({
email: z.string().email(),
}).refine(
async (data) => {
const exists = await checkEmailExists(data.email);
return !exists;
},
{ message: "Email already exists" }
);Cross-Field Validation
Validate relationships between fields:
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"],
}
);Validation Best Practices
1. Validate Early, Validate Often
Define schemas for all inputs to catch errors early:
// ✅ Good - validates everything
const contract = createContract({
getUser: {
path: "/users/:id",
pathParams: z.object({ id: z.string().uuid() }),
query: z.object({ include: z.array(z.string()).optional() }),
headers: z.object({ authorization: z.string() }),
responses: { /* ... */ },
},
});2. Use Descriptive Error Messages
// ✅ Good
z.string().email({ message: "Please provide a valid email address" })
// ❌ Bad
z.string().email()3. Provide Sensible Defaults
query: z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(10),
sort: z.enum(['asc', 'desc']).default('asc'),
})4. Validate Path Parameters Explicitly
For non-string types, always provide explicit schemas:
// ✅ Good
pathParams: z.object({
id: z.string().uuid(),
version: z.string().transform(Number).pipe(z.number().int()),
})5. Handle Optional Fields Properly
// ✅ Good - explicit optional
query: z.object({
filter: z.string().optional(),
limit: z.number().default(10), // Has default, so always present
})Related Topics
- Contracts - Learn about defining validation schemas
- Type Safety - Understand type inference from validation
- Error Handling - Customize error responses