> hypequery

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

On this page