How to Implement Validation Logic in Controllers Using Zod and Chanfana
Implement validation logic in controllers by defining a Zod schema on the controller's schema property and calling this.getValidatedData() to automatically parse and validate incoming requests against that schema.
The castrozan/tcc repository demonstrates a clean approach to request validation by co-locating validation rules directly within controller classes. This pattern leverages the chanfana framework's OpenAPIRoute base class combined with Zod schemas to ensure type-safe, self-documenting API endpoints.
Understanding the Validation Architecture in Castrozan/TCC
The validation system in this repository relies on four core components working together to enforce data integrity at the controller layer.
OpenAPIRoute (from chanfana) serves as the base class for all API controllers. It automatically reads the schema property defined on each controller class and wires together request validation, OpenAPI metadata generation, and response description.
z (Zod) provides the declarative schema language used to define validation rules. The repository uses Zod to specify required fields, string lengths, URL formats, nullable types, and custom error messages directly in the controller.
getValidatedData<T>() is the method inherited from OpenAPIRoute that executes the validation logic. When called inside the controller's handle method, it parses the incoming request body against the Zod schema defined in schema.request.body and returns a strongly-typed object.
withErrorHandling is a decorator imported from presentation/decorators that wraps the handle method. It catches validation errors thrown by Zod or runtime errors from the use case layer and converts them into proper HTTP 400 or 500 responses.
How to Implement Validation Logic in Controllers Step by Step
Follow this implementation pattern to add validation to any controller in the castrozan/tcc architecture.
Step 1 – Define the Zod Schema
Create a Zod object schema that describes the expected structure of the request body. Include validation constraints such as .min(), .url(), or .optional() to enforce business rules.
import { z } from 'zod';
const CreateProfessionalSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
email: z.string().email({ message: 'Invalid email format' }),
phone: z.string().optional(),
specialty: z.string().min(1)
});
Step 2 – Extend OpenAPIRoute and Configure the Schema Property
Create a controller class that extends OpenAPIRoute from chanfana. Assign the Zod schema to the schema.request.body.content['application/json'].schema property to register it with the framework.
import { OpenAPIRoute } from 'chanfana';
import { withErrorHandling } from 'presentation/decorators';
export class CreateProfessionalController extends OpenAPIRoute {
schema = {
tags: ['Professionals'],
summary: 'Create a new professional',
request: {
body: {
content: {
'application/json': {
schema: CreateProfessionalSchema
}
}
}
},
responses: {
'201': {
description: 'Professional created successfully'
},
'400': {
description: 'Validation error'
}
}
};
// Handle method implementation follows...
}
Step 3 – Validate Incoming Requests with getValidatedData
Inside the handle method, call this.getValidatedData<typeof this.schema>() to parse and validate the request. Destructure the validated fields from the returned object and pass them to your use case.
@withErrorHandling
async handle(): Promise<object> {
// Validates against CreateProfessionalSchema and returns typed data
const data = await this.getValidatedData<typeof this.schema>();
const { name, email, phone, specialty } = data.body;
const useCase = new CreateProfessionalUseCase(professionalRepository);
const professional = await useCase.execute({
name,
email,
phone,
specialty
});
return {
success: true,
result: {
id: professional.id,
name: professional.name
}
};
}
}
Step 4 – Handle Errors with the withErrorHandling Decorator
Apply the @withErrorHandling decorator to the handle method. This ensures that Zod validation errors (such as missing required fields or invalid email formats) are caught and returned as HTTP 400 responses with descriptive error messages, while unexpected runtime errors return as 500 responses.
Complete Example: Creating a Professional Controller
Here is the full implementation from professionals-dummy-app/src/presentation/controllers/professionals/CreateProfessionalController.ts, demonstrating how validation logic in controllers integrates with the broader application architecture:
import { Bool, OpenAPIRoute } from 'chanfana';
import { withErrorHandling } from 'presentation/decorators';
import { z } from 'zod';
import { CreateProfessionalUseCase } from 'application/use-cases/professionals/CreateProfessionalUseCase';
import professionalRepository from 'infrastructure/database/repositories/professional';
export class CreateProfessionalController extends OpenAPIRoute {
schema = {
tags: ['Professionals'],
summary: 'Create a new professional',
security: [{ bearerAuth: [] }],
request: {
body: {
content: {
'application/json': {
schema: z.object({
name: z.string().min(1, { message: 'Name is required' }),
email: z.string().email({ message: 'Invalid email format' }),
phone: z.string().optional(),
specialty: z.string().min(1, { message: 'Specialty is required' })
})
}
}
}
},
responses: {
'201': {
description: 'Created successfully',
content: {
'application/json': {
schema: z.object({
success: Bool(),
result: z.object({
id: z.number(),
name: z.string(),
email: z.string()
})
})
}
}
},
'400': {
description: 'Validation error'
}
}
};
@withErrorHandling
async handle(): Promise<object> {
const data = await this.getValidatedData<typeof this.schema>();
const { name, email, phone, specialty } = data.body;
const useCase = new CreateProfessionalUseCase(professionalRepository);
const professional = await useCase.execute({ name, email, phone, specialty });
return {
success: true,
result: {
id: professional.id,
name: professional.name,
email: professional.email
}
};
}
}
Reusing Validation Schemas Across Multiple Controllers
To maintain DRY (Don't Repeat Yourself) principles when you implement validation logic in controllers, extract common Zod schemas into separate files and import them where needed. This approach is particularly useful when multiple controllers share similar data structures, such as Create and Update operations.
Create a dedicated validation file:
// src/presentation/validation/professionalSchemas.ts
import { z } from 'zod';
export const ProfessionalBaseSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
email: z.string().email({ message: 'Invalid email format' }),
phone: z.string().optional(),
specialty: z.string().min(1, { message: 'Specialty is required' })
});
export const CreateProfessionalSchema = ProfessionalBaseSchema;
export const UpdateProfessionalSchema = z.object({
id: z.number().min(1, { message: 'ID is required' }),
data: ProfessionalBaseSchema.partial()
});
Then reference these schemas in your controllers:
import { CreateProfessionalSchema } from 'presentation/validation/professionalSchemas';
export class CreateProfessionalController extends OpenAPIRoute {
schema = {
request: {
body: {
content: {
'application/json': { schema: CreateProfessionalSchema }
}
}
}
// ... rest of schema
};
// ...
}
This pattern ensures consistency across your API surface while keeping the validation logic in controllers declarative and maintainable.
Summary
- Implement validation logic in controllers by extending
OpenAPIRoutefrom the chanfana framework and defining a Zod schema on theschemaproperty. - Use
this.getValidatedData<typeof this.schema>()inside thehandlemethod to automatically parse and validate incoming requests against the defined schema. - Apply the
@withErrorHandlingdecorator to ensure validation errors return as HTTP 400 responses with descriptive messages. - Extract reusable Zod schemas into separate validation files to maintain DRY principles across multiple controllers.
- Reference implementations in
CreateProfessionalController.ts,UpdateProfessionalController.ts, andCreateEquipmentController.tsfor complete working examples.
Frequently Asked Questions
What is Chanfana and how does it handle validation?
Chanfana is a framework that provides the OpenAPIRoute base class used throughout the castrozan/tcc repository. It handles validation by reading the schema property defined on each controller class, automatically parsing incoming requests against the Zod schema specified in schema.request.body, and providing the getValidatedData() method to return strongly-typed, validated data to the controller's business logic.
How does getValidatedData() work in the controller?
The getValidatedData() method is inherited from OpenAPIRoute and is called within the controller's handle method. It extracts the raw request body, validates it against the Zod schema defined in the controller's schema.request.body.content['application/json'].schema property, and returns a typed object containing the validated data. If validation fails, it throws an error that is caught by the @withErrorHandling decorator and returned as an HTTP 400 response.
Can I reuse Zod schemas across different controllers?
Yes, you can extract Zod schemas into separate files and import them into multiple controllers. This is recommended for maintaining DRY principles when multiple endpoints share similar data structures. Create a dedicated validation file (e.g., src/presentation/validation/professionalSchemas.ts), export your Zod schemas, and reference them in the schema.request.body property of any controller that needs to implement validation logic.
How are validation errors handled in this architecture?
Validation errors are handled by the @withErrorHandling decorator applied to the controller's handle method. When getValidatedData() encounters data that fails Zod validation, it throws an error containing detailed messages about which fields failed and why. The withErrorHandling decorator catches these errors and converts them into HTTP 400 (Bad Request) responses with descriptive error messages, while unexpected runtime errors are returned as HTTP 500 responses.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →