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 Valibot

The 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 Entity

Body 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) => parsedData function: 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:

core/controllers/post_controller.ts
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 }))
}