Kynetra FX

Documentation

Validation

Kynetra FX validates request data at the boundary — before the handler runs — when a contract defines input or query schemas. Validation failures produce a stable, machine-readable 422 response.

When validation runs#

FX validates selectively based on the method and which schema fields are present in the contract definition:

NameTypeDescription
inputPOST · PUT · PATCHValidates the request body when an input schema is set. Body must be valid JSON. Other methods ignore input even if a schema is defined.
queryall methodsValidates parsed query string parameters when a query schema is set. Applies to GET, POST, DELETE, and all other methods.
outputnever (runtime)Not validated at runtime. Used only for OpenAPI generation and typed client response types.

If neither input nor query is set on a route, no validation runs and the handler is called immediately with the raw context.

Note

FX never validates output at runtime. The output schema is used only for OpenAPI generation and typed client response types.

The 422 error shape#

Every validation failure returns HTTP 422 with a JSON body that follows a stable, typed structure. This shape is the same regardless of which schema library produced the error.

422 response body
{
"error": {
"code": "FX_VALIDATION_ERROR",
"message": "Request validation failed",
"details": {
"issues": [
{
"location": "body",
"path": ["address", "zip"],
"message": "Expected string, received undefined"
}
]
}
}
}

Each object in the issues array describes one validation failure. FX collects all issues before responding — it does not stop at the first failure.

NameTypeDescription
location'body' | 'query'Where the issue originated. body for input schema failures, query for query schema failures.
path(string | number)[]JSON path to the failing field. An empty array means the root value failed. Array indices appear as numbers (e.g. ["items", 0, "name"]).
messagestringHuman-readable description of the validation failure, forwarded from the underlying schema library.

Body validation#

Body validation runs on POST, PUT, and PATCH requests when an input schema is set. The raw request body is parsed as JSON before being handed to the schema. If the body is not valid JSON — empty, malformed, or the wrong content type — FX returns HTTP 422 (not 400) before the schema is even invoked.

terminal
# Missing required field
curl -X POST https://api.example.com/users \
-H 'Content-Type: application/json' \
-d '{"name": "Ada"}'
422 response
{
"error": {
"code": "FX_VALIDATION_ERROR",
"message": "Request validation failed",
"details": {
"issues": [
{
"location": "body",
"path": ["email"],
"message": "Required"
}
]
}
}
}
terminal
# Malformed JSON body
curl -X POST https://api.example.com/users \
-H 'Content-Type: application/json' \
-d 'not json at all'
422 response — invalid JSON
{
"error": {
"code": "FX_VALIDATION_ERROR",
"message": "Request validation failed",
"details": {
"issues": [
{
"location": "body",
"path": [],
"message": "Invalid JSON"
}
]
}
}
}

Query validation#

Query validation runs on any HTTP method when a query schema is set. Query string values are always strings at the HTTP layer; your schema is responsible for coercing types (e.g. parsing "42" into a number). Both the FX built-in schema builder and Standard Schema–compliant libraries work for query schemas.

Validated query parameters are available as ctx.validatedQuery inside the handler, typed according to the schema's inferred output type.

src/routes/products.ts
import { app } from '@kynetra/fx'
import { z } from 'zod'
 
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
category: z.string().optional(),
})
 
app.route({
method: 'GET',
path: '/products',
query: listQuerySchema,
async handler(ctx) {
// ctx.validatedQuery is typed as z.infer<typeof listQuerySchema>
const { page, limit, category } = ctx.validatedQuery
const products = await db.products.list({ page, limit, category })
return ctx.json({ products, page, limit })
},
})
terminal
# page must be a positive integer
curl 'https://api.example.com/products?page=0&limit=20'
422 response
{
"error": {
"code": "FX_VALIDATION_ERROR",
"message": "Request validation failed",
"details": {
"issues": [
{
"location": "query",
"path": ["page"],
"message": "Number must be greater than or equal to 1"
}
]
}
}
}

Multiple issues#

FX does not stop at the first validation failure. All issues from the schema are collected and returned together in a single 422 response. This lets clients surface all problems to the user at once rather than correcting errors one at a time.

422 response — multiple issues
{
"error": {
"code": "FX_VALIDATION_ERROR",
"message": "Request validation failed",
"details": {
"issues": [
{
"location": "body",
"path": ["email"],
"message": "Invalid email address"
},
{
"location": "body",
"path": ["password"],
"message": "String must contain at least 12 characters"
},
{
"location": "body",
"path": ["role"],
"message": "Invalid enum value. Expected 'admin' | 'member' | 'viewer'"
}
]
}
}
}

The number of issues returned depends on what the underlying schema library reports. FX forwards all issues without truncation.

Handling validation errors on the client#

The typed client distinguishes success and error responses through the ok discriminant. When a request fails with 422, ok is false and the response data contains the full error shape. TypeScript narrows the type automatically once you check ok.

src/client.ts
import { createClient } from '@kynetra/fx/client'
import type { App } from '../src/app'
 
const client = createClient<App>({ baseUrl: 'https://api.example.com' })
 
async function createUser(data: unknown) {
const res = await client.POST['/users'](data)
 
if (!res.ok) {
if (res.status === 422) {
// res.data.error is typed as FXValidationError
const issues = res.data.error.details.issues
for (const issue of issues) {
console.error(
`[${issue.location}] ${issue.path.join('.')} — ${issue.message}`
)
}
}
return null
}
 
// res.data is typed as the output schema inferred type
return res.data
}

Custom error handling#

If you need to transform or replace the default 422 response — for example to match a different error envelope, add a request ID, or log to an external service — you can intercept validation errors with an onError hook. The hook receives an FXError whose code is FX_VALIDATION_ERROR for validation failures.

src/app.ts
import { app } from '@kynetra/fx'
 
app.onError((err, ctx) => {
if (err.code === 'FX_VALIDATION_ERROR') {
return ctx.json(
{
type: 'validation_error',
requestId: ctx.requestId,
fields: err.details.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
422
)
}
// let other errors fall through to the default handler
return ctx.json({ error: err.message }, 500)
})

See Hooks for the full onError API and other lifecycle hooks available in Kynetra FX.