Validation
Validate request bodies, route parameters, query strings, and headers using Zod, Yup, or Valibot.
Introduction
@tekir/validator provides a single validate() function that wraps any schema library into a tekir middleware. When validation passes, the validated (and transformed) data replaces the raw source on ctx. When it fails, a ValidationError is thrown; tekir catches it and returns a structured 422 Unprocessable Entity response automatically.
Because validate() is a standard middleware function, you can attach it to a single route, to a route group, or to a controller method via the @Middleware decorator.
Installation
Install the validator package together with your preferred schema library:
# Install the validator + your preferred schema library
bun add @tekir/validator zod # Zod (recommended)
# bun add @tekir/validator yup # or Yup
# bun add @tekir/validator valibot # or ValibotThe validate() Middleware
validate(options) returns a middleware function. The options object can have up to four keys, one for each data source you want to validate:
import { validate } from '@tekir/validator'
// validate() returns a MiddlewareFunction.
// Pass it to .use() on a route or to @Middleware on a controller method.
//
// validate({
// body?: schema, // validate ctx.body
// params?: schema, // validate ctx.params
// query?: schema, // validate ctx.query
// headers?: schema, // validate ctx.headers
// })
//
// On success: the validated (and possibly transformed) data replaces the source
// On failure: throws ValidationError → 422 Unprocessable EntityBody Validation
Pass a schema to body to validate ctx.body. On success, the schema's output (including defaults and transforms) replaces ctx.body.
import { z } from 'zod'
import { validate } from '@tekir/validator'
import type { TekirApp } from '@tekir/core'
const createUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
role: z.enum(['user', 'admin']).default('user')
})
export default function({ router }: TekirApp) {
router.post('/users', async ({ body, response }) => {
// body is already validated and matches the schema.
// Zod defaults are applied: body.role is 'user' if not provided.
return response.created(body)
}).use(validate({ body: createUserSchema }))
}Params Validation
Pass a schema to params to validate ctx.params. This is useful for casting string parameters to numbers or UUIDs before your handler runs.
import { z } from 'zod'
import { validate } from '@tekir/validator'
const idSchema = z.object({
id: z.string().regex(/^\d+$/, 'ID must be numeric').transform(Number)
})
router.get('/users/:id', async ({ params, response }) => {
// params.id is now a number (Zod's transform ran)
return response.ok({ id: params.id })
}).use(validate({ params: idSchema }))Query Validation
Pass a schema to query to validate ctx.query. All query string values arrive as strings, so use Zod's .transform() to coerce them to numbers or booleans.
import { z } from 'zod'
import { validate } from '@tekir/validator'
const paginationSchema = z.object({
page: z.string().optional().transform(v => Number(v) || 1),
perPage: z.string().optional().transform(v => Math.min(Number(v) || 20, 100)),
sort: z.enum(['asc', 'desc']).default('asc')
})
router.get('/posts', async ({ query, response }) => {
// query.page, query.perPage, query.sort are typed and validated
return response.ok({ page: query.page, perPage: query.perPage, sort: query.sort })
}).use(validate({ query: paginationSchema }))Headers Validation
Pass a schema to headers to validate ctx.headers. Header names are always lowercase. Use this to enforce required API keys or custom headers.
import { z } from 'zod'
import { validate } from '@tekir/validator'
const apiKeySchema = z.object({
'x-api-key': z.string().min(32, 'API key too short')
})
router.get('/api/data', handler).use(
validate({ headers: apiKeySchema })
)Combining Multiple Sources
You can validate all four sources in a single validate() call. They are validated in parallel and all errors are collected before throwing. If body, params, and query all fail, you get one error response with all the problems listed.
import { z } from 'zod'
import { validate } from '@tekir/validator'
const paramsSchema = z.object({
userId: z.string().regex(/^\d+$/).transform(Number)
})
const bodySchema = z.object({
title: z.string().min(3).max(200),
body: z.string().min(10),
status: z.enum(['draft', 'published']).default('draft')
})
const querySchema = z.object({
notify: z.string().optional().transform(v => v === 'true')
})
// All three schemas are validated in parallel
router.post('/users/:userId/posts', async ({ params, body, query, response }) => {
return response.created({ ...body, userId: params.userId, notify: query.notify })
}).use(validate({ params: paramsSchema, body: bodySchema, query: querySchema }))Schema Libraries
@tekir/validator is schema-library agnostic. It detects the library by checking which methods exist on the schema object:
.parseAsync(data)or.parse(data): Zod (and any library that throws on failure and returns parsed data on success)..validate(data, options): Yup.- A plain
(data) => parsedDatafunction: anything custom, including Valibot via a thin wrapper.
Zod
import { z } from 'zod'
import { validate } from '@tekir/validator'
// Zod uses .parseAsync() or .parse() internally
const schema = z.object({
email: z.string().email(),
age: z.number().int().min(18)
}).strict() // disallow unknown keys
router.post('/register', handler).use(validate({ body: schema }))Yup
Yup's .validate() is called with { abortEarly: false, stripUnknown: true } so you always get all errors at once and extra keys are stripped.
import * as yup from 'yup'
import { validate } from '@tekir/validator'
// Yup uses .validate({ abortEarly: false, stripUnknown: true }) internally
const schema = yup.object({
email: yup.string().email().required(),
age: yup.number().integer().min(18).required()
})
router.post('/register', handler).use(validate({ body: schema }))Valibot
Valibot does not expose a .parse() method directly on its schema objects. Wrap it in a thin adapter:
import { object, string, email, number, integer, minValue, parse } from 'valibot'
import { validate } from '@tekir/validator'
// Valibot schemas must be wrapped in a .parse() adapter:
const rawSchema = object({
email: string([email()]),
age: number([integer(), minValue(18)])
})
// Wrap in an object with a .parse() method that tekir recognises
const schema = { parse: (data: unknown) => parse(rawSchema, data) }
router.post('/register', handler).use(validate({ body: schema }))Error Format
When validation fails, tekir responds with HTTP 422 Unprocessable Entityand a JSON body in this shape:
// When validation fails, @tekir/validator throws a ValidationError.
// tekir's error handler catches it and responds with HTTP 422:
{
"error": {
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"statusCode": 422,
"fields": {
"email": ["Invalid email"],
"password": ["Password must be at least 8 characters"],
"age": ["Expected number, received string"]
}
}
}
// Each key in "fields" is the field path (dot-notation for nested fields).
// Each value is an array of error messages for that field.Nested field paths use dot-notation. For example, an error on address.city appears under the key "address.city".
ValidationError
You can catch or inspect the ValidationError class directly in your own error handling logic:
import { ValidationError } from '@tekir/validator'
// ValidationError shape:
class ValidationError extends Error {
statusCode: 422
code: 'VALIDATION_ERROR'
fields: Record<string, string[]> // { fieldName: ['error message', ...] }
toJSON(): {
error: {
message: string
code: string
statusCode: 422
fields: Record<string, string[]>
}
}
}Customising Error Responses
If the default 422 format does not match your API design, wrap the chain in a middleware that catches ValidationError and returns your own format:
// In middleware or a custom exception handler:
import { ValidationError } from '@tekir/validator'
// Catch ValidationError and return a custom format
router.useGlobal([async (ctx, next) => {
try {
await next()
} catch (err) {
if (err instanceof ValidationError) {
return ctx.response.unprocessableEntity({
success: false,
errors: err.fields
})
}
throw err
}
}])Using validate() in Routes
The most common pattern is attaching validate() via .use() on individual routes:
import type { TekirApp } from '@tekir/core'
import { validate } from '@tekir/validator'
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(3).max(200),
body: z.string().min(10),
status: z.enum(['draft', 'published']).default('draft')
})
const updatePostSchema = createPostSchema.partial() // all fields optional
export default function({ router }: TekirApp) {
router.get('/api/posts', async ({ response }) => {
return response.ok([])
})
router.post('/api/posts', async ({ body, response }) => {
return response.created(body)
}).use(validate({ body: createPostSchema }))
router.put('/api/posts/:id', async ({ params, body, response }) => {
return response.ok({ id: params.id, ...body })
}).use(validate({ body: updatePostSchema }))
}