Validation
Asena provides built-in validation support using Zod. Validation ensures that incoming request data meets your requirements before reaching your route handlers.
Adapter Support
Both the Ergenecore and the Hono adapter ship validation. Each exports its own ValidationService and ValidationSchemaWithHook, so import them from the adapter you use.
One behavioural difference remains: Ergenecore runs the hook only when validation fails, while the Hono adapter runs it on every validation attempt. Write hooks that check result.success and they behave the same on both.
Installation
Zod is a peer dependency of both adapters — they define the validation contract, they do not ship the library, so your project owns the version:
bun add zodZod v4 or higher is required. Both adapters call flattenError to build the validation-failure envelope, and that is a top-level export introduced in Zod 4.
You may already have it
asena create installs zod for you, and asena generate validator writes import { z } from 'zod' into the file it scaffolds. If you set the project up by hand, add it — before 3.0.0 the import resolved by hoisting out of the adapter's own dependencies, which was never something to rely on.
Why Use Validation?
- Type Safety: Catch type mismatches at runtime
- Security: Prevent malicious or malformed input
- Better Error Messages: Provide clear validation feedback
- Self-Documentation: Schema serves as API documentation
- Automatic Error Handling: 400 Bad Request responses automatically
Quick Start
1. Create a Validator
Extend ValidationService and implement the json() method:
import { Middleware } from '@asenajs/asena/decorators';
import { ValidationService } from '@asenajs/ergenecore';
import { z } from 'zod';
@Middleware({ validator: true })
export class CreateUserValidator extends ValidationService {
json() {
return z.object({
name: z.string().min(3).max(50),
email: z.string().email(),
age: z.number().min(18).max(120)
});
}
}2. Apply to a Route
Use the validator option in route decorators:
import { Controller } from '@asenajs/asena/decorators';
import { Post } from '@asenajs/asena/decorators/http';
import type { Context } from '@asenajs/ergenecore';
@Controller('/users')
export class UserController {
@Post({ path: '/', validator: CreateUserValidator })
async create(context: Context) {
const body = await context.getBody();
// body is guaranteed to be valid!
return context.send({ created: true, user: body }, 201);
}
}Validation Happens Automatically
The validator runs before your route handler. If validation fails, the handler is never called.
ValidationService API
The json() Method
The json() method defines the validation schema for request bodies:
@Middleware({ validator: true })
export class MyValidator extends ValidationService {
json() {
// Return a Zod schema
return z.object({
// Your validation rules
});
}
}Method Signature:
json(): ValidationSchema | Promise<ValidationSchema>- Return Type: A Zod schema (
z.ZodType) - Async Support: Can be
asyncfor dynamic schemas
Validating other request parts
json() is the most common, but a validator can define any of these - each takes the same shape (a Zod schema, or { schema, hook }) and validates a different part of the request:
| Method | Validates | Runtime |
|---|---|---|
json() | JSON request body | ✅ |
form() | multipart/form-data / URL-encoded body | ✅ |
query() | Query string | ✅ |
param() | Route parameters | ✅ |
header() | Request headers | ✅ |
response() | Response shape, by status code | ❌ documentation only |
@Middleware({ validator: true })
export class ListUsersValidator extends ValidationService {
public query() {
return z.object({
page: z.string().transform(Number).pipe(z.number().min(1)),
});
}
public param() {
return z.object({ id: z.string().uuid() });
}
}response() is never executed
It exists so @asenajs/asena-openapi can emit response schemas into the spec; the runtime ignores it.
A consequence worth knowing: @Middleware({ validator: true }) requires at least one of the five runtime methods. A validator class that defines only response() fails the decorator's check at import time and the process exits.
Basic Example
@Middleware({ validator: true })
export class ProductValidator extends ValidationService {
json() {
return z.object({
name: z.string(),
price: z.number().positive(),
category: z.enum(['electronics', 'clothing', 'food']),
inStock: z.boolean()
});
}
}Async Validation
For database lookups or async checks:
@Middleware({ validator: true })
export class UniqueEmailValidator extends ValidationService {
@Inject(UserRepository)
private userRepo: UserRepository;
async json() {
return z.object({
email: z.string().email().refine(async (email) => {
const exists = await this.userRepo.findByEmail(email);
return !exists;
}, 'Email already exists')
});
}
}Validation Hooks
Use ValidationSchemaWithHook to execute custom logic after validation:
import { type ValidationSchemaWithHook, ValidationService } from '@asenajs/ergenecore';
@Middleware({ validator: true })
export class UserValidatorWithHook extends ValidationService {
json(): ValidationSchemaWithHook {
return {
schema: z.object({
email: z.string().email(),
password: z.string().min(8)
}),
hook: (result, context) => {
// result: Zod SafeParseResult - { success, data } or { success, error }
// context: Asena's Context wrapper
if (!result.success) {
// Return nothing to let the framework report the failure through onError
return;
}
console.log('Validated user data:', result.data);
// Store in context for later use
context.setValue('validatedEmail', result.data.email);
}
};
}
}import { type ValidationSchemaWithHook, ValidationService } from '@asenajs/hono-adapter';
@Middleware({ validator: true })
export class UserValidatorWithHook extends ValidationService {
json(): ValidationSchemaWithHook {
return {
schema: z.object({
email: z.string().email(),
password: z.string().min(8)
}),
hook: (result, context) => {
// result: Zod SafeParseResult - { success, data } or { success, error }
// context: Hono's NATIVE context, not Asena's wrapper - see the warning below
if (!result.success) {
// Return nothing to let the framework report the failure through onError
return;
}
console.log('Validated user data:', result.data);
// Hono's own state API - set()/get(), not setValue()/getValue()
context.set('validatedEmail', result.data.email);
}
};
}
}The hook's context differs per adapter
This is the one place where the two adapters are not interchangeable.
- Ergenecore passes Asena's
Contextwrapper, sosetValue()/getValue()/send()are available. - Hono types the hook as
@hono/zod-validator'sHook, which hands you Hono's native context. Useset()/get()to store state andjson()to respond.
The values you store are still readable from the handler's Asena context - setValue and Hono's set write to the same per-request store.
Hook Function Signature
hook: (
result: z.ZodSafeParseResult<T>,
context: Context
) => Response | void | Promise<Response | void>| Parameter | Type | Description |
|---|---|---|
result | z.ZodSafeParseResult<T> | Zod's safe-parse result: { success: true, data } or { success: false, error } |
context | Context | The adapter's Context object |
| Returns | Response | void | Return a Response to answer the request yourself; return nothing to continue |
The hook receives the parse result, not the parsed data
result is not the validated object - the data lives at result.data, and only when result.success is true. Always branch on result.success first.
The return value is not a way to transform the payload. Returning a plain object does nothing; only a Response is honoured, and it short-circuits the request.
Hook Use Cases
1. Logging and Monitoring
hook: (result, context) => {
console.log('Validation attempted:', {
endpoint: context.req.url,
ok: result.success
});
}Returning nothing leaves the error contract untouched - a failure is still reported the normal way.
2. Storing Validated Data in Context
hook: (result, context) => {
if (!result.success) return;
// Make validated data available to middlewares/handlers
context.setValue('validatedUser', result.data);
context.setValue('userEmail', result.data.email);
}3. Answering the Request Yourself
hook: (result, context) => {
if (result.success) return;
// A Response short-circuits everything - the handler never runs and
// onError is never called for this request
return context.send({ error: 'Invalid signup payload' }, 422);
}4. Side Effects
hook: async (result, context) => {
if (!result.success) return;
// Send notification, update cache, etc.
await this.notificationService.sendAlert('New signup', result.data.email);
}Validation Error Responses
When validation fails, Asena answers with 400 Bad Request.
Default response
A ValidationError is always thrown. When nothing answers it - your application defines no global error handler, or its handler returns nothing - both adapters fall back to the same envelope, Zod's flattened error:
{
"error": "Validation failed",
"details": {
"formErrors": [],
"fieldErrors": {
"email": ["Invalid email"],
"age": ["Must be at least 18"]
}
},
"target": "json"
}target names the part of the request that failed - json, query, form, param or header.
The failure is also logged like any other 4xx, at DEBUG (or INFO when your logger has no debug). Before 0.9.1 an application with no onError got this response from inside the validator, which never threw - so it reached neither onError nor the log, the one rejection that was invisible at every level. See Error Handling.
Customizing the response
Define onError in your @Config class and validation failures arrive there like any other error, so they can share your application's response envelope:
import { Config } from '@asenajs/asena/decorators';
import { isValidationError } from '@asenajs/asena/adapter';
import { ConfigService, type Context } from '@asenajs/hono-adapter';
@Config()
export class ServerConfig extends ConfigService {
public onError(error: Error, context: Context): Response {
if (isValidationError(error)) {
return context.send(
{
success: false,
message: 'Validation failed',
errors: error.issues // [{ path, message, code }]
},
400
);
}
return context.send({ success: false, message: 'Internal Server Error' }, 500);
}
}isValidationError() is exported from @asenajs/asena/adapter and works with both adapters. The error it narrows to also carries target and cause (the original ZodError) if you need more than issues.
Existing error handlers keep working
The thrown error is an HTTP exception carrying status 400, so a handler that matches with isHttpException() and replies with error.status keeps answering 400 - adopting this does not silently turn validation failures into 500s.
Check isValidationError() first if you want validation failures to have their own envelope. A ValidationError is an HTTP exception too, so a generic isHttpException() branch placed above it would swallow it.
The class each adapter extends is an implementation detail and they differ - Ergenecore's extends HttpException, the Hono adapter's extends hono's HTTPException so that pre-0.9 handlers written against instanceof HTTPException keep answering 400. Match with the guards and you do not have to know which.
A hook that returns a Response wins
If the validator's hook returns a Response, that response is sent as-is and onError is never reached for that request. See Validation Hooks.
Integration with Controllers
Route-Level Validation
@Controller('/products')
export class ProductController {
@Post({ path: '/', validator: CreateProductValidator })
async create(context: Context) {
// Body is already validated
const product = await context.getBody();
return context.send(product, 201);
}
@Put({ path: '/:id', validator: UpdateProductValidator })
async update(context: Context) {
const id = context.getParam('id');
const updates = await context.getBody();
return context.send({ updated: true });
}
}Combining with Middleware
@Post({
path: '/',
middlewares: [AuthMiddleware, RateLimitMiddleware],
validator: CreateUserValidator
})
async create(context: Context) {
// Ergenecore: AuthMiddleware -> RateLimitMiddleware -> CreateUserValidator -> handler
// Hono: CreateUserValidator -> AuthMiddleware -> RateLimitMiddleware -> handler
}Validators and route middleware run in a different order per adapter
Ergenecore validates after the route's middleware chain; Hono registers validators before route middleware, so validation happens first.
This matters when a middleware populates state the validator depends on - an AuthMiddleware that calls context.setValue('user', …) has not run yet on Hono. Global middleware is unaffected: it is top-level on both adapters and always runs first.
Zod Schema Definition
Asena uses Zod for schema definition. The json() method returns a Zod schema:
json() {
return z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().positive()
});
}Reusable Schemas
You can extract and reuse common schemas across multiple validators:
// src/validators/schemas/common.ts
import { z } from 'zod';
// Reusable schema definitions
export const emailSchema = z.string().email().toLowerCase();
export const passwordSchema = z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/);
export const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
zipCode: z.string().regex(/^\d{5}$/),
country: z.string().length(2)
});
export const paginationSchema = z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(10)
});Then use them in your validators:
import { Middleware } from '@asenajs/asena/decorators';
import { ValidationService } from '@asenajs/ergenecore';
import { emailSchema, passwordSchema, addressSchema } from '../schemas/common';
import { z } from 'zod';
@Middleware({ validator: true })
export class CreateUserValidator extends ValidationService {
json() {
return z.object({
email: emailSchema, // Reuse common schema
password: passwordSchema, // Reuse common schema
shippingAddress: addressSchema // Reuse common schema
});
}
}
@Middleware({ validator: true })
export class UpdateUserValidator extends ValidationService {
json() {
return z.object({
email: emailSchema.optional(), // Reuse and modify
shippingAddress: addressSchema.partial() // All fields optional
});
}
}Benefits of Reusable Schemas
- Consistency: Same validation rules across your app
- Maintainability: Update once, apply everywhere
- Type Safety: Share types between validators
- Composition: Build complex schemas from simple ones
Zod API Documentation
For complete schema documentation including strings, numbers, arrays, objects, unions, transformations, and refinements, see the Zod API Documentation.
Asena-Specific Best Practices
1. Use Validators for All User Input
// ✅ Good: Validate all POST/PUT/PATCH requests
@Post({ path: '/', validator: CreateUserValidator })
async create(context: Context) { }2. Keep Validators Focused
// ✅ Good: One validator per endpoint
@Middleware({ validator: true })
export class CreateUserValidator extends ValidationService {
json() {
return z.object({
name: z.string(),
email: z.string().email()
});
}
}3. Use Hooks for Context Integration
// ✅ Good: Use hooks to enrich context
hook: (result, context) => {
context.setValue('userId', result.id);
context.setValue('userRole', result.role);
}4. Leverage Dependency Injection
// ✅ Good: Inject services in validators
@Middleware({ validator: true })
export class UniqueUsernameValidator extends ValidationService {
@Inject(UserService)
private userService: UserService;
async json() {
return z.object({
username: z.string().refine(async (name) => {
return !(await this.userService.usernameExists(name));
}, 'Username already taken')
});
}
}ValidationSchema Types
// Simple schema
type ValidationSchema = z.ZodType<any, z.ZodTypeDef, any>;
// Schema with hook
interface ValidationSchemaWithHook {
schema: ValidationSchema;
hook?: (result: any, context: Context) => any;
}Complete Example
Here's a real-world validator with hooks and service injection:
import { Middleware } from '@asenajs/asena/decorators';
import { ValidationService, type ValidationSchemaWithHook } from '@asenajs/ergenecore';
import { Inject } from '@asenajs/asena/decorators/ioc';
import { z } from 'zod';
@Middleware({ validator: true })
export class RegisterUserValidator extends ValidationService {
@Inject(UserService)
private userService: UserService;
@Inject(Logger)
private logger: Logger;
async json(): Promise<ValidationSchemaWithHook> {
return {
schema: z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20)
.regex(/^[a-zA-Z0-9_]+$/, 'Alphanumeric and underscore only')
.refine(async (name) => {
const exists = await this.userService.usernameExists(name);
return !exists;
}, 'Username already taken'),
email: z.string()
.email('Invalid email')
.transform(v => v.toLowerCase()),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[0-9]/, 'Must contain number'),
confirmPassword: z.string(),
terms: z.literal(true, {
errorMap: () => ({ message: 'Must accept terms' })
})
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
}),
// The hook receives Zod's SafeParseResult - the data lives at `result.data`
// and only when `result.success` is true
hook: (result, context) => {
if (!result.success) {
// Returning a Response overrides the adapter's default 400 envelope
return context.send({ success: false, issues: result.error.issues }, 400);
}
this.logger.info('User registration validated', {
username: result.data.username,
email: result.data.email
});
// Stash the parsed data for the handler to pick up with context.getValue()
const { confirmPassword, ...userData } = result.data;
context.setValue('registrationData', userData);
}
};
}
}The hook cannot transform the payload
The return value is a Response override, not a transformed body. Returning a plain object is silently ignored - the handler still receives the original parsed data. To pass a reshaped value along, write it to the context with setValue() as above.
Note also that Ergenecore invokes the hook only on failure, while Hono invokes it on every attempt. Guard on result.success so the same hook works under both adapters.
Related Documentation
- Controllers - Using validators in controllers
- Middleware - Understanding middleware flow
- Ergenecore Adapter - Ergenecore-specific features
- Context API - Working with Context in hooks
- Zod Documentation - Complete Zod schema guide
Next Steps:
- Learn about Middleware
- Explore Error Handling
- Understand Context API