Schemas
Validate ClickHouse query inputs and outputs with Zod. Use schemas for type-safe TypeScript APIs, runtime validation, and OpenAPI generation.
Input/Output Schemas
Use Zod schemas to validate incoming requests and document expected responses. This provides runtime validation, compile-time type safety, and automatic OpenAPI schema generation.
Why Schemas?
Schemas provide:
- Runtime validation - Reject invalid requests before query execution
- Type safety - Full TypeScript inference from Zod to your handlers
- Auto-documentation - OpenAPI schemas generated automatically
- Error handling - Detailed validation errors returned to clients
- AI agent compatibility - JSON Schema for tool discovery
Input Schemas
Define what data your endpoint accepts:
import { initServe } from '@hypequery/serve'; import { z } from 'zod'; const { query, serve } = initServe({ context: () => ({ db }), }); const revenue = query({ input: z.object({ startDate: z.string().datetime(), endDate: z.string().datetime(), currency: z.enum(['USD', 'EUR', 'GBP']).default('USD'), includeRefunds: z.boolean().optional(), }), query: async ({ ctx, input }) => { // input is fully typed based on the input schema const { startDate, endDate, currency } = input; return ctx.db .table('transactions') .where('date', 'gte', startDate) .where('date', 'lte', endDate) .where('currency', 'eq', currency) .sum('amount', 'total') .execute(); }, }); export const api = serve({ queries: { revenue }, });
Validation Errors
Invalid requests return detailed error messages:
{
"error": {
"type": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": {
"issues": [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": ["startDate"],
"message": "Expected string, received number"
}
]
}
}
}
Output Schemas
Document what your endpoint returns:
Output schemas are optional for in-process usage. If you omit them, TypeScript infers the return type directly from your resolver and api.run() remains fully typed. However, without an output, OpenAPI docs, api.describe(), and external agents won't see structured response metadata. Add one whenever the query is exposed outside your codebase.
const revenue = query({ input: z.object({ startDate: z.string(), endDate: z.string(), }), output: z.object({ total: z.number(), currency: z.string(), breakdown: z.array(z.object({ date: z.string(), amount: z.number(), transactionCount: z.number(), })), metadata: z.object({ generatedAt: z.string(), queryDurationMs: z.number(), }), }), query: async ({ input }) => { // Return value is type-checked against the output schema return { total: 42000, currency: 'USD', breakdown: [ { date: '2025-01-01', amount: 10000, transactionCount: 50 }, { date: '2025-01-02', amount: 12000, transactionCount: 60 }, ], metadata: { generatedAt: new Date().toISOString(), queryDurationMs: 123, }, }; }, });
Common Schema Patterns
Date Ranges
const dateRangeSchema = z.object({ startDate: z.string().datetime(), endDate: z.string().datetime(), }).refine( (data) => new Date(data.startDate) < new Date(data.endDate), { message: 'startDate must be before endDate' } ); queries: { metrics: query({ input: dateRangeSchema, query: async ({ input }) => { /* ... */ }, }), }
Unions and Discriminated Unions
const reportSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('revenue'), currency: z.enum(['USD', 'EUR']), includeRefunds: z.boolean(), }), z.object({ type: z.literal('users'), includeInactive: z.boolean(), segment: z.enum(['free', 'paid', 'enterprise']), }), z.object({ type: z.literal('performance'), metric: z.enum(['latency', 'throughput', 'errors']), percentile: z.number().min(50).max(99), }), ]); queries: { generateReport: query({ input: reportSchema, query: async ({ input }) => { switch (input.type) { case 'revenue': return generateRevenueReport(input.currency, input.includeRefunds); case 'users': return generateUserReport(input.includeInactive, input.segment); case 'performance': return generatePerformanceReport(input.metric, input.percentile); } }, }), }
Reusable Schemas
Define shared schemas once and reuse them:
// schemas/common.ts import { z } from 'zod'; export const dateRangeSchema = z.object({ startDate: z.string().datetime(), endDate: z.string().datetime(), }); export const currencySchema = z.enum(['USD', 'EUR', 'GBP', 'JPY']); export const paginationInputSchema = z.object({ page: z.number().int().positive().default(1), pageSize: z.number().int().min(1).max(100).default(20), }); export const paginationOutputSchema = <T extends z.ZodTypeAny>(dataSchema: T) => z.object({ data: z.array(dataSchema), pagination: z.object({ page: z.number(), pageSize: z.number(), totalPages: z.number(), totalCount: z.number(), }), }); // api/index.ts import { dateRangeSchema, currencySchema, paginationOutputSchema } from './schemas/common'; const userSchema = z.object({ id: z.string(), email: z.string(), name: z.string(), }); const revenue = query({ input: dateRangeSchema.extend({ currency: currencySchema, }), query: async ({ input }) => { /* ... */ }, }); const users = query({ input: paginationInputSchema, output: paginationOutputSchema(userSchema), query: async ({ input }) => { /* ... */ }, }); const api = serve({ queries: { revenue, users }, });
Type Inference
TypeScript automatically infers types from your schemas:
const userSchema = z.object({ id: z.string(), email: z.string().email(), name: z.string(), age: z.number().int().positive().optional(), }); type User = z.infer<typeof userSchema>; // type User = { // id: string; // email: string; // name: string; // age?: number | undefined; // } const createUser = query({ input: userSchema, output: userSchema.extend({ createdAt: z.string().datetime(), }), query: async ({ input }) => { // input: User (fully typed!) const user = await db.table('users').insert(input).returning('*'); return { ...user, createdAt: new Date().toISOString(), }; }, });
OpenAPI Generation
Schemas automatically generate OpenAPI documentation:
const revenue = query({ input: z.object({ startDate: z.string().datetime().describe('Start of date range (ISO 8601)'), endDate: z.string().datetime().describe('End of date range (ISO 8601)'), currency: z.enum(['USD', 'EUR']).default('USD').describe('Currency code'), }), output: z.object({ total: z.number().describe('Total revenue in specified currency'), transactionCount: z.number().int().describe('Number of transactions'), }), summary: 'Get revenue for date range', description: 'Returns total revenue and transaction count', query: async ({ input }) => { /* ... */ }, }); // Auto-generates OpenAPI spec with detailed parameter descriptions