Skip to content

Validation ​

Asena provides built-in validation support for the Ergenecore adapter using Zod. Validation ensures that incoming request data meets your requirements before reaching your route handlers.

Adapter Support

Validation is currently supported only in the Ergenecore adapter. The Hono adapter can use Zod directly with Hono's validation middleware.

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:

typescript
import { Middleware } from '@asenajs/asena/server';
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:

typescript
import { Controller } from '@asenajs/asena/server';
import { Post } from '@asenajs/asena/web';
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:

typescript
@Middleware({ validator: true })
export class MyValidator extends ValidationService {
  json() {
    // Return a Zod schema
    return z.object({
      // Your validation rules
    });
  }
}

Method Signature:

typescript
json(): ValidationSchema | Promise<ValidationSchema>
  • Return Type: A Zod schema (z.ZodType)
  • Async Support: Can be async for dynamic schemas

Basic Example ​

typescript
@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:

typescript
@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:

typescript
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: validated data
        // context: Ergenecore Context

        // Log validation success
        console.log('Validated user data:', result);

        // Store in context for later use
        context.setValue('validatedEmail', result.email);

        // Transform or enrich data
        return {
          ...result,
          emailLowercase: result.email.toLowerCase()
        };
      }
    };
  }
}
typescript
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: validated data
        // context: Ergenecore Context

        // Log validation success
        console.log('Validated user data:', result);

        // Store in context for later use
        context.setValue('validatedEmail', result.email);

        // Transform or enrich data
        return {
          ...result,
          emailLowercase: result.email.toLowerCase()
        };
      }
    };
  }
}

Hook Function Signature ​

typescript
hook: (result: any, context: Context) => any
ParameterTypeDescription
resultanyThe validated and parsed data from Zod
contextContextErgenecore Context object
ReturnsanyTransformed data (optional)

Hook Use Cases ​

1. Logging and Monitoring

typescript
hook: (result, context) => {
  console.log('Validation passed:', {
    endpoint: context.req.url,
    data: result
  });
}

2. Storing Validated Data in Context

typescript
hook: (result, context) => {
  // Make validated data available to middlewares/handlers
  context.setValue('validatedUser', result);
  context.setValue('userEmail', result.email);
}

3. Data Transformation

typescript
hook: (result, context) => {
  // Transform validated data
  return {
    ...result,
    email: result.email.toLowerCase(),
    createdAt: new Date(),
    ipAddress: context.req.headers.get('x-forwarded-for')
  };
}

4. Side Effects

typescript
hook: async (result, context) => {
  // Send notification, update cache, etc.
  await this.notificationService.sendAlert('New signup', result.email);
}

Validation Error Responses ​

When validation fails, Asena automatically returns 400 Bad Request with detailed error information:

json
{
  "error": "Validation failed",
  "details": [
    {
      "path": ["email"],
      "message": "Invalid email"
    },
    {
      "path": ["age"],
      "message": "Must be at least 18"
    }
  ]
}

Custom Error Handling

You cannot customize the error response format directly. For custom error handling, use a global error handler in your @Config class.

Integration with Controllers ​

Route-Level Validation ​

typescript
@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 ​

typescript
@Post({
  path: '/',
  middlewares: [AuthMiddleware, RateLimitMiddleware],
  validator: CreateUserValidator
})
async create(context: Context) {
  // Execution order:
  // 1. AuthMiddleware
  // 2. RateLimitMiddleware
  // 3. CreateUserValidator (validation)
  // 4. create() handler
}

Zod Schema Definition ​

Asena uses Zod for schema definition. The json() method returns a Zod schema:

typescript
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:

typescript
// 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:

typescript
import { Middleware } from '@asenajs/asena/server';
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 ​

typescript
// ✅ Good: Validate all POST/PUT/PATCH requests
@Post({ path: '/', validator: CreateUserValidator })
async create(context: Context) { }

2. Keep Validators Focused ​

typescript
// ✅ 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 ​

typescript
// ✅ Good: Use hooks to enrich context
hook: (result, context) => {
  context.setValue('userId', result.id);
  context.setValue('userRole', result.role);
}

4. Leverage Dependency Injection ​

typescript
// ✅ 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 ​

typescript
// 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:

typescript
import { Middleware } from '@asenajs/asena/server';
import { ValidationService, type ValidationSchemaWithHook } from '@asenajs/ergenecore';
import { Inject } from '@asenajs/asena/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']
      }),

      hook: (result, context) => {
        // Log validation success
        this.logger.info('User registration validated', {
          username: result.username,
          email: result.email
        });

        // Store validated data in context
        context.setValue('registrationData', result);

        // Remove confirmPassword from result
        const { confirmPassword, ...userData } = result;
        return userData;
      }
    };
  }
}

Next Steps:

Released under the MIT License.