Validation
Data validation is a critical part of any robust application. Harpia encourages a schema-based validation approach and uses Zod as its built-in validation library due to its TypeScript-first design and excellent developer experience.
Structuring Validations
In Harpia’s modular architecture, validation schemas belong inside the validations/ directory of their respective module. This ensures that the validation rules remain close to the controllers, services, and repositories that use them.
modules/
│
└── user/
├── validations/
│ ├── index.ts
│ ├── create.ts
│ └── update.ts
...
When you generate a module via the CLI (bun harpia generate module), the create and update validation templates are automatically scaffolded for you.
Creating a Schema
Harpia CLI templates adopt an exception-driven approach using Zod’s parse() or parseAsync() methods. The validation function validates the payload and throws an AppError or ZodError if validation fails. Controllers then gracefully catch and format this error using the ApiResponse.error utility.
Here is the standard structure generated for resource creation:
// modules/user/validations/create.ts
import { AppError } from "@harpiats/common";
import * as z from "zod";
export const SchemaCreateBody = z.object({
name: z.string().min(1).max(255),
});
// Zod's type inference provides strong typing across your whole stack
export type SchemaCreateBodyType = z.infer<typeof SchemaCreateBody>;
export async function create(data: SchemaCreateBodyType) {
try {
SchemaCreateBody.parse(data);
} catch (error) {
if (error instanceof z.ZodError || error instanceof AppError) {
throw error;
}
throw AppError.E_GENERIC_ERROR("Unexpected error during validation.");
}
}
Validating Updates
When validating updates, the requirements are often slightly different. You usually need to validate both the URL parameters (like an id) and the request body, where fields might be optional.
Harpia’s CLI separates this concern into two schemas: SchemaUpdateParams and SchemaUpdateBody. It also cleverly determines your id type (e.g., UUID vs. Auto-increment Integer) straight from your schema.prisma.
// modules/user/validations/update.ts
import { AppError } from "@harpiats/common";
import * as z from "zod";
import { SchemaCreateBody } from "./create";
// 1. Validates the URL parameters (e.g., the ID)
export const SchemaUpdateParams = z.object({
id: z.uuid("ID must be a valid UUID."), // Or z.number().int() depending on your Prisma schema
});
// 2. Validates the body, inheriting fields from SchemaCreateBody
export const SchemaUpdateBody = SchemaCreateBody
.omit({})
.partial()
.extend({})
.strict()
.refine(
(data) => Object.keys(data).length > 0,
{ message: "No data provided for update." }
);
export type SchemaUpdateParamsType = z.infer<typeof SchemaUpdateParams>;
export type SchemaUpdateBodyType = z.infer<typeof SchemaUpdateBody>;
// 3. Validates both pieces of data and returns them
export async function update(paramsData: unknown, bodyData: unknown) {
try {
const params = await SchemaUpdateParams.parseAsync(paramsData);
const body = await SchemaUpdateBody.parseAsync(bodyData);
return { params, body };
} catch (error) {
if (error instanceof z.ZodError || error instanceof AppError) {
throw error;
}
throw AppError.E_GENERIC_ERROR("Unexpected error during validation.");
}
}
Implementing Validation in Controllers
The most common place to execute validations is within your Controllers, right before passing data to your Services.
Store Controller Example
The store controller is typically responsible for creating resources.
// modules/user/controllers/store.ts
import type { Request, Response } from "@harpiats/core";
import { ApiResponse } from "@harpiats/common";
import { service } from "../services";
import { validation } from "../validations";
export async function store(request: Request, response: Response) {
try {
const body = await request.json();
// Automatically validates and throws if it fails
await validation.create(body);
const data = await service.create(body);
return ApiResponse.success(response, data, 201);
} catch (error: any) {
// The ApiResponse utility automatically handles ZodErrors and AppErrors
return ApiResponse.error(response, error);
}
}
Update Controller Example
For updates, you pass both the route parameters and the request body to the validation layer. The validation function ensures everything is correct and returns the parsed, sanitized body.
// modules/user/controllers/update.ts
import type { Request, Response } from "@harpiats/core";
import { ApiResponse } from "@harpiats/common";
import { service } from "../services";
import { validation } from "../validations";
import type { UserType } from "app/database";
export async function update(request: Request, response: Response) {
try {
const { id } = request.params as unknown as { id: UserType["id"] };
const parsedId: string = String(id);
const body = await request.json();
// Validate params and body simultaneously
const { body: validatedBody } = await validation.update({ id }, body);
// Pass the parsed ID and validated body to the service layer
const data = await service.update(parsedId, validatedBody);
return ApiResponse.success(response, data);
} catch (error: any) {
return ApiResponse.error(response, error);
}
}
Leveraging Type Inference
Observe in the examples above that we export types like SchemaCreateBodyType and SchemaUpdateBodyType using z.infer. This enables you to reuse the strict typing defined by your validation schema across your Controllers, Services, and Repositories without needing to manually define separate TypeScript interfaces. This is one of the biggest benefits of using Zod with Harpia!
Why Zod?
Using Zod provides several advantages:
- Type Safety: The parsed data is automatically typed, preventing runtime errors.
- Sanitisation: Zod automatically strips out any unexpected fields from the payload if you configure it to do so (like
.strict()). - Readability: Schema definitions serve as a single source of truth and excellent documentation for your API endpoints.