Validate Plugin
Runtime schema validation for requests and responses using the validatePlugin.
Overview
The validatePlugin adds validation methods to each route in your contract, enabling runtime schema validation for path parameters, query strings, request bodies, headers, and responses.
Installation
The validate plugin is included in @ts-contract/plugins:
pnpm add @ts-contract/pluginsBasic Usage
Add the plugin to your contract using initContract():
import { createContract, initContract } from '@ts-contract/core';
import { validatePlugin } from '@ts-contract/plugins';
import { z } from 'zod';
const contract = createContract({
createUser: {
method: 'POST',
path: '/users',
body: z.object({
name: z.string(),
email: z.string().email(),
}),
responses: {
201: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
},
},
});
const api = initContract(contract)
.use(validatePlugin)
.build();
// Validate request body
const body = api.createUser.validateBody({
name: 'Alice',
email: '[email protected]',
});
// Validate response
const user = api.createUser.validateResponse(201, {
id: '123',
name: 'Alice',
email: '[email protected]',
});Validation Methods
The validate plugin adds five validation methods to each route:
validatePathParams()
Validates path parameters against the route's pathParams schema.
validatePathParams(params: unknown): InferPathParams<Route>Example:
const contract = createContract({
getUser: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string().uuid() }),
responses: {
200: z.object({ id: z.string(), name: z.string() }),
},
},
});
const api = initContract(contract).use(validatePlugin).build();
// ✓ Valid
const params = api.getUser.validatePathParams({ id: '550e8400-e29b-41d4-a716-446655440000' });
// => { id: '550e8400-e29b-41d4-a716-446655440000' }
// ✗ Throws validation error
api.getUser.validatePathParams({ id: 'not-a-uuid' });validateQuery()
Validates query string parameters against the route's query schema.
validateQuery(query: unknown): InferQuery<Route>Example:
const contract = createContract({
listUsers: {
method: 'GET',
path: '/users',
query: z.object({
page: z.string().transform(Number),
limit: z.string().transform(Number).optional(),
}),
responses: {
200: z.array(z.object({ id: z.string() })),
},
},
});
const api = initContract(contract).use(validatePlugin).build();
// ✓ Valid - transforms strings to numbers
const query = api.listUsers.validateQuery({ page: '2', limit: '10' });
// => { page: 2, limit: 10 }
// ✗ Throws validation error
api.listUsers.validateQuery({ page: 'invalid' });validateBody()
Validates request body against the route's body schema.
validateBody(body: unknown): InferBody<Route>Example:
const contract = createContract({
createUser: {
method: 'POST',
path: '/users',
body: z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).optional(),
}),
responses: {
201: z.object({ id: z.string() }),
},
},
});
const api = initContract(contract).use(validatePlugin).build();
// ✓ Valid
const body = api.createUser.validateBody({
name: 'Alice',
email: '[email protected]',
age: 30,
});
// ✗ Throws validation error - invalid email
api.createUser.validateBody({
name: 'Alice',
email: 'not-an-email',
});validateResponse()
Validates response data against the route's response schema for a specific status code.
validateResponse<Status>(status: Status, data: unknown): InferResponseBody<Route, Status>Example:
const contract = createContract({
getUser: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
}),
404: z.object({
message: z.string(),
}),
},
},
});
const api = initContract(contract).use(validatePlugin).build();
// ✓ Valid - 200 response
const user = api.getUser.validateResponse(200, {
id: '123',
name: 'Alice',
email: '[email protected]',
});
// ✓ Valid - 404 response
const error = api.getUser.validateResponse(404, {
message: 'User not found',
});
// ✗ Throws validation error - wrong schema for status
api.getUser.validateResponse(200, {
message: 'User not found',
});validateHeaders()
Validates request headers against the route's headers schema.
validateHeaders(headers: Record<string, unknown>): InferHeaders<Route>Example:
const contract = createContract({
getProtected: {
method: 'GET',
path: '/protected',
headers: {
'authorization': z.string().startsWith('Bearer '),
'x-api-key': z.string(),
},
responses: {
200: z.object({ data: z.string() }),
},
},
});
const api = initContract(contract).use(validatePlugin).build();
// ✓ Valid
const headers = api.getProtected.validateHeaders({
'authorization': 'Bearer token123',
'x-api-key': 'key123',
});
// ✗ Throws validation error
api.getProtected.validateHeaders({
'authorization': 'token123', // Missing "Bearer " prefix
'x-api-key': 'key123',
});Error Handling
Validation errors throw with descriptive messages:
try {
api.createUser.validateBody({
name: '',
email: 'invalid-email',
});
} catch (error) {
console.error(error.message);
// => "Validation failed for body of /users: String must contain at least 1 character(s), Invalid email"
}Error messages include:
- What failed (pathParams, query, body, response, headers)
- Which route (path)
- Specific validation issues from your schema library
Integration with Standard Schema
The validate plugin works with any schema library that implements @standard-schema/spec including Zod, Valibot, and Arktype.
Server-Side Validation
Use validation methods to validate incoming request data:
import express from 'express';
const app = express();
app.use(express.json());
app.post('/users', (req, res) => {
try {
// Validate request body
const body = api.createUser.validateBody(req.body);
// Create user with validated data
const user = database.createUser(body);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ message: error.message });
}
});
app.get('/users/:id', (req, res) => {
try {
// Validate path parameters
const params = api.getUser.validatePathParams(req.params);
const user = database.findUser(params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
} catch (error) {
res.status(400).json({ message: error.message });
}
});Client-Side Validation
Validate API responses to ensure type safety:
async function fetchUser(id: string) {
const url = api.getUser.buildPath({ id });
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
const error = api.getUser.validateResponse(404, await response.json());
throw new Error(error.message);
}
throw new Error('Request failed');
}
const data = await response.json();
// Validate response data
return api.getUser.validateResponse(200, data);
}Common Patterns
React Query with Validation
import { useQuery } from '@tanstack/react-query';
function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async () => {
const url = api.getUser.buildPath({ id });
const response = await fetch(url);
const data = await response.json();
// Validate response
return api.getUser.validateResponse(200, data);
},
});
}Conditional Validation
Only validate in development:
async function fetchUser(id: string) {
const response = await fetch(api.getUser.buildPath({ id }));
const data = await response.json();
if (process.env.NODE_ENV === 'development') {
return api.getUser.validateResponse(200, data);
}
return data;
}Type-Safe Error Responses
async function fetchUser(id: string) {
const response = await fetch(api.getUser.buildPath({ id }));
const data = await response.json();
if (response.status === 404) {
const error = api.getUser.validateResponse(404, data);
throw new Error(error.message);
}
if (!response.ok) {
throw new Error('Request failed');
}
return api.getUser.validateResponse(200, data);
}Performance Considerations
Validation has runtime cost. Always validate user input and external API responses. Consider skipping validation for trusted internal services or in production if performance is critical.
// Conditional validation
const shouldValidate = process.env.NODE_ENV !== 'production';
async function fetchUser(id: string) {
const response = await fetch(api.getUser.buildPath({ id }));
const data = await response.json();
return shouldValidate ? api.getUser.validateResponse(200, data) : data;
}Troubleshooting
"Route has no [field] schema" Error
Problem: Trying to validate a field that doesn't exist in the route.
Solution: Ensure your route defines the schema you're trying to validate:
// ✗ Error - no body schema
const contract = createContract({
getUser: {
method: 'GET',
path: '/users/:id',
responses: { 200: z.object({ id: z.string() }) },
},
});
api.getUser.validateBody(data); // Error!
// ✓ Correct - body schema defined
const contract = createContract({
createUser: {
method: 'POST',
path: '/users',
body: z.object({ name: z.string() }), // ← Add this
responses: { 201: z.object({ id: z.string() }) },
},
});
api.createUser.validateBody(data); // Works!Validation Passes but TypeScript Errors
Problem: Validation succeeds at runtime but TypeScript shows errors.
Solution: Make sure you're using the validated result, not the original data:
// ✗ Wrong - using original data
const data = await response.json();
api.getUser.validateResponse(200, data);
console.log(data.name); // TypeScript error
// ✓ Correct - using validated result
const data = await response.json();
const user = api.getUser.validateResponse(200, data);
console.log(user.name); // TypeScript knows this existsNext Steps
- Learn about Path Plugin for URL building
- See Creating Custom Plugins to build your own
- Explore Recipes for real-world validation examples
- Review Plugin System for how plugins work