Skip to content

Validation

defineHandler wraps a route handler to add schema validation and type inference. It accepts any library that implements the Standard Schema spec — Zod, Valibot, and ArkType all work out of the box.

import { defineHandler } from 'lacis'
import { z } from 'zod'
export const GET = defineHandler({
params: z.object({ id: z.string().uuid() }),
query: z.object({ verbose: z.coerce.boolean().optional() }),
meta: { summary: 'Get user by ID', tags: ['users'] },
handler: async (req, res) => {
req.params.id // string — validated and typed
req.query.verbose // boolean | undefined — validated and typed
res.json({ id: req.params.id })
},
})

The return value is a plain async function — export it like any other route handler.

Each field accepts a Standard Schema. Schemas are validated in order: params first, then query, then body. If any fails, Lacis returns 400 immediately.

  • params — validated against req.params (URL path parameters)
  • query — validated against req.query (parsed query string)
  • body — Lacis calls req.json() internally and validates the result

Attached to the OpenAPI operation when the spec is generated. No effect at request time.

meta: {
summary: 'Create user',
description: 'Creates a new user account.',
tags: ['users'],
deprecated: false,
}

A map of status code → Standard Schema. Declaring it does two things from one source of truth: it types the handler’s res (see Typed responses) and it feeds the OpenAPI spec.

responses: {
200: z.object({ id: z.string(), name: z.string() }),
404: z.object({ error: z.string() }),
}

Per-route, per-method middleware — runs after path +middleware, before the handler. See Per-route middleware.

use: [rateLimit, auth]

The actual request handler with fully typed req and res.

When validation fails, Lacis sends a 400 automatically:

{
"error": "Validation failed",
"issues": [
{ "message": "Invalid uuid", "path": ["id"] }
]
}

The handler is never called when validation fails.

Query strings are always strings. Use your validator’s coercion utilities:

query: z.object({
page: z.coerce.number().int().positive(),
active: z.coerce.boolean().optional(),
})
import { defineHandler } from 'lacis'
import { z } from 'zod'
export const POST = defineHandler({
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
meta: { summary: 'Create user', tags: ['users'] },
handler: async (req, res) => {
const { name, email } = req.body // fully typed
res.status(201).json({ name, email })
},
})
export const PUT = defineHandler({
params: z.object({ id: z.string().uuid() }),
query: z.object({ dryRun: z.coerce.boolean().default(false) }),
body: z.object({ name: z.string(), email: z.string().email() }),
handler: async (req, res) => {
if (req.query.dryRun) return res.json({ ok: true, dry: true })
res.json({ id: req.params.id, ...req.body })
},
})

When you declare responses, the output side becomes type-safe too: res.status(code).json(data) only accepts the schema declared for that status code. It’s opt-in and non-breaking — without responses, res stays the regular Response.

export const GET = defineHandler({
responses: {
200: z.object({ id: z.string(), name: z.string() }),
404: z.object({ error: z.string() }),
},
handler: async (req, res) => {
res.status(200).json({ id: '1', name: 'Ada' }) // ✓ matches the 200 schema
res.status(404).json({ id: 1 }) // ✗ type error — 404 wants { error: string }
},
})

TypeScript infers the output types of your schemas. Change a schema, and the types in handler update automatically — no manual annotations needed.