Request & Response
Balda provides a clean and intuitive API for handling HTTP requests and responses. The framework provides custom Request and Response classes with built-in compatibility layers for seamless interoperability with Web API Request/Response objects through fromRequest(), toWebApi(), and toWebResponse() methods.
Request Object
The request object contains information about the incoming HTTP request.
Basic Properties
interface Request {
method: string; // HTTP method (GET, POST, etc.)
url: string; // Request URL
path: string; // Request path
headers: Record<string, string>; // Request headers
query: Record<string, string>; // Query parameters
params: Record<string, string>; // Route parameters
body: any; // Parsed request body from body parser middleware
ip: string; // Client IP address
cookies: Record<string, string>; // Request cookies
}
Balda's Request class provides compatibility with Web API Request objects through:
Request.fromRequest(webRequest): Converts a Web API Request to a Balda Requestreq.toWebApi(): Converts a Balda Request back to a Web API Requestreq.body: Contains the parsed request body set by body parser middleware (JSON, URL-encoded, or file uploads). Use this in your route handlers.
Accessing Request Data
@get('/users/:id')
async getUser(req: Request, res: Response) {
// Route parameters
const userId = req.params.id;
// Query parameters
const { page = 1, limit = 10 } = req.query;
// Headers
const userAgent = req.headers['user-agent'];
const authToken = req.headers.authorization;
// Parsed request body (for POST/PUT/PATCH, automatically set by body parser middleware)
const userData = req.body; // Available when using body parser middleware (json, urlencoded, or file)
// Client IP
const clientIP = req.ip;
res.json({ userId, page, limit, userAgent, clientIP });
}
Type-Safe Request-Response
Add TypeScript types to requests and responses for full type safety:
import { Request, Response } from 'balda';
// Type path parameters and response
type UserResponse = { id: string; name: string; email: string };
@get('/users/:id')
async getUser(
req: Request<{ id: string }>,
res: Response<UserResponse>
) {
res.json({ id: req.params.id, name: "John", email: "john@example.com" });
}
// Validated data passed as typed arguments
const CreateUserSchema = z.object({ name: z.string(), email: z.string() });
@post('/users')
@validate.body(CreateUserSchema)
async createUser(
req: Request<{}>,
res: Response<UserResponse>,
body: z.infer<typeof CreateUserSchema> // ✅ Validated & typed
) {
res.created({ id: '123', ...body });
}
// All together: params + body + query + response
@post('/:userId/posts')
@validate.body(CreatePostSchema)
@validate.query(PostQuerySchema)
async createPost(
req: Request<{ userId: string }>,
res: Response<PostResponse>,
body: z.infer<typeof CreatePostSchema>,
query: z.infer<typeof PostQuerySchema>
) {
const { userId } = req.params;
const { title, content } = body;
const { draft = false } = query;
res.created({ id: 'post-123', userId, title, content, draft });
}
Note: req.body and req.query are untyped until validated - use the validated arguments after @validate decorators.
File Uploads
Handle file uploads with bodyType: 'form-data':
import { controller, post } from 'balda';
import { z } from 'zod';
@controller('/upload')
export class UploadController {
@post('/', { bodyType: 'form-data' })
async upload(req, res) {
const file = req.file('fieldName'); // Get file by field name
if (!file) {
return res.badRequest({ error: 'No file uploaded' });
}
res.ok({
originalName: file.originalName, // Original filename
formName: file.formName, // Form field name
size: file.size, // File size in bytes
mimeType: file.mimeType, // MIME type
content: file.content // File content as Uint8Array
});
// Other form fields available in req.body
const { description } = req.body;
}
}
Configure file upload limits:
const server = new Server({
plugins: {
bodyParser: {
fileParser: {
maxFiles: 10,
maxFileSize: '10mb',
}
}
}
});
Response Object
The response object provides methods for sending HTTP responses.
Status Methods
import { Request, Response } from 'balda';
@get('/users')
async getUsers(req: Request, res: Response) {
// Success responses
res.ok({ users: [] }); // 200 OK
res.created({ id: 1 }); // 201 Created
res.accepted({ jobId: 123 }); // 202 Accepted
res.noContent(); // 204 No Content
// Client error responses
res.badRequest({ error: 'Invalid data' }); // 400 Bad Request
res.unauthorized({ error: 'Auth required' }); // 401 Unauthorized
res.forbidden({ error: 'Access denied' }); // 403 Forbidden
res.notFound({ error: 'User not found' }); // 404 Not Found
res.conflict({ error: 'User exists' }); // 409 Conflict
res.tooManyRequests({ error: 'Rate limited' }); // 429 Too Many Requests
// Server error responses
res.internalServerError({ error: 'Server error' }); // 500 Internal Server Error
res.notImplemented({ error: 'Not implemented' }); // 501 Not Implemented
res.badGateway({ error: 'Bad gateway' }); // 502 Bad Gateway
res.serviceUnavailable({ error: 'Unavailable' }); // 503 Service Unavailable
}
Content Methods
import { Request, Response } from 'balda';
@get('/api/data')
async getData(req: Request, res: Response) {
// Manually set headers
res.setHeader('X-Custom-Header', 'value');
// JSON response
res.json({ message: 'Hello World' });
// Text response
res.text('Hello World');
// HTML response
res.html('<h1>Hello World</h1>');
// Redirect
res.redirect('/new-location');
// Download file
res.download('/path/to/file.pdf', 'filename.pdf');
// Send tries to understand the best content type to send based on the body (better to use specific methods)
res.send({ message: 'Hello World' });
}
Header Management
import { Request, Response } from 'balda';
@get('/api/data')
async getData(req: Request, res: Response) {
// Set headers
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-cache');
// Set multiple headers
res.setHeaders({
'Content-Type': 'application/json',
'X-Custom-Header': 'value'
});
// Get headers
const contentType = res.getHeader('Content-Type');
res.json({ data: 'value' });
}
Cookie Management
import { Request, Response } from 'balda';
@post('/login')
async login(req: Request, res: Response) {
// Set cookies
res.setCookie('sessionId', 'abc123', {
httpOnly: true,
secure: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
// Clear cookies
res.clearCookie('oldSession');
res.json({ message: 'Logged in' });
}
Request Validation
Built-in Validation
Balda supports validation using Zod schemas or raw JSON schemas. Validation is powered by AJV for performance. Zod is a peer dependency loaded only when Zod schemas are used, also zod schemas are compiled once and cached during the server lifecycle so it won't be compiled on each request. Zod4 is required with the toJSONSchema() method in order to work.
The validated data is passed as the next argument to the route handler (can be stacked with other validation decorators).
import { Request, Response, validate } from 'balda';
import z from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(18)
});
@post('/users')
@validate.body(CreateUserSchema)
async createUser(req: Request, res: Response, body: z.infer<typeof CreateUserSchema>) {
// body is validated and typed
const { name, email, age } = body;
res.created({ name, email, age });
}
Query Parameter Validation
import { Request, Response, validate } from 'balda';
import z from 'zod';
const QuerySchema = z.object({
page: z.number().min(1).optional(),
limit: z.number().min(1).max(100).optional()
});
@get('/users')
@validate.query(QuerySchema)
async getUsers(req: Request, res: Response, query: any) {
const { page = 1, limit = 10 } = query;
res.json({ page, limit });
}
Using JSON Schemas Directly
@post('/users')
@validate.body({
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
age: { type: 'number', minimum: 18 }
},
required: ['name', 'email']
})
async createUser(req: Request, res: Response, body: any) {
// body parameter contains the validated data
// req.body also contains the validated data
res.created(body);
}
Using TypeBox Schemas
TypeBox provides type-safe JSON Schema with full TypeScript type inference. It's a lightweight alternative to Zod with excellent performance.
import { Request, Response, validate } from 'balda';
import { Type, Static } from '@sinclair/typebox';
const CreateUserSchema = Type.Object({
name: Type.String({ minLength: 1 }),
email: Type.String({ format: 'email' }),
age: Type.Number({ minimum: 18 })
});
type CreateUser = Static<typeof CreateUserSchema>;
@post('/users')
@validate.body(CreateUserSchema)
async createUser(req: Request, res: Response, body: CreateUser) {
// body is validated and typed
const { name, email, age } = body;
res.created({ name, email, age });
}
TypeBox also supports advanced schema compositions:
import { Type, Static } from '@sinclair/typebox';
// Partial updates
const UpdateUserSchema = Type.Partial(
Type.Object({
name: Type.String(),
email: Type.String({ format: 'email' }),
age: Type.Number({ minimum: 18 })
})
);
// Arrays
const UsersArraySchema = Type.Array(
Type.Object({
id: Type.Number(),
name: Type.String(),
email: Type.String()
})
);
// Union types
const ResponseSchema = Type.Union([
Type.Object({ success: Type.Literal(true), data: Type.Any() }),
Type.Object({ success: Type.Literal(false), error: Type.String() })
]);
// Optional fields
const QuerySchema = Type.Object({
page: Type.Optional(Type.Number({ minimum: 1 })),
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 }))
});
TypeBox schemas are automatically cached just like Zod schemas. Each schema object is compiled to AJV once on first use, then reused for all subsequent validations. This makes TypeBox validation extremely fast—especially since TypeBox schemas are already JSON Schema compliant and don't need conversion.
All schemas (Zod, TypeBox, and plain JSON schemas) are compiled once and cached for the lifetime of the server:
- Zod schemas: Compiled from Zod → JSON Schema → AJV on first use, then cached
- TypeBox schemas: Compiled directly to AJV on first use (already JSON Schema), then cached
- Plain JSON schemas: Compiled directly to AJV on first use, then cached
Schema objects are tracked via WeakMap to ensure stable caching across your application. This means zero compilation overhead on subsequent requests—validation is as fast as possible after the first use.
Both Zod and TypeBox are peer dependencies installed only if you use their schemas. They're lazy-loaded at runtime when needed, not bundled if unused.
# Install Zod (requires zod4 with toJSONSchema() method)
npm install zod
# Install TypeBox
npm install @sinclair/typebox
# Or install both if you want to use them together
npm install zod @sinclair/typebox
Why use Zod?
- Rich validation API with async refinements support (via
.parseAsync()) - Excellent for complex validation logic
- Great ecosystem and community
- Schemas are converted to JSON Schema, then compiled and cached
Why use TypeBox?
- Lightweight and fast (generates pure JSON Schema)
- Excellent type inference with
Static<T> - Perfect for API-first development
- Smaller bundle size
- Already JSON Schema compliant—compiles directly to AJV for maximum performance
Caching Behavior: Both libraries benefit from automatic schema caching. Define your schemas once, use them everywhere—compilation happens only on first use, then cached for the server's lifetime.
// These schemas are compiled once and cached forever
const UserSchema = Type.Object({ name: Type.String() });
const ProductSchema = z.object({ name: z.string() });
// First request: compiles and caches
// All subsequent requests: uses cached compiled schema ⚡
Both libraries are first-class citizens in Balda and work seamlessly with validation decorators.
Built-in validation supports synchronous Zod schemas only. Async refinements (.refine(async () => ...)) are not supported.
For async validation, use Zod's .parseAsync() directly:
@post('/users')
async createUser(req: Request, res: Response) {
const result = await CreateUserSchema.safeParseAsync(req.body); // req.body automatically set by body parser middleware
if (!result.success) return res.badRequest(result.error);
res.created(result.data);
}
Custom AJV Instance
You can provide your own AJV instance with custom configuration for advanced use cases:
import { Ajv } from 'ajv';
import { AjvStateManager } from 'balda';
const customAjv = new Ajv({
validateSchema: false, // Required - must be false
strict: false, // Required - must be false
allErrors: true, // Optional - collect all errors
coerceTypes: true, // Optional - type coercion
// ... other custom options
});
// Add custom formats
customAjv.addFormat('custom-format', /^[A-Z]{3}-\d{4}$/);
// Add custom keywords
customAjv.addKeyword('isOdd', {
type: 'number',
validate: (schema: any, data: number) => data % 2 === 1
});
// Set as global instance (must be called before any validation)
AjvStateManager.setGlobalInstance(customAjv);
The following AJV options are required and must not be changed:
validateSchema: false- Required for proper Zod schema compilationstrict: false- Required for proper Zod schema compilation
Changing these values will cause validation errors and break Zod schema support.
Consider customizing the AJV instance when you need:
- Custom validation formats (e.g., specific ID patterns)
- Custom validation keywords
- Different error reporting (
allErrors: true) - Type coercion (
coerceTypes: true) - Performance tuning for specific use cases
Response Serialization
Schema-based Serialization
You can use Zod, TypeBox, or plain JSON schemas for response serialization:
import { Request, Response, serialize } from 'balda';
import z from 'zod';
import { Type } from '@sinclair/typebox';
// Using Zod
const UserSchemaZod = z.object({
id: z.number(),
name: z.string(),
email: z.string()
});
@get('/users')
@serialize(z.array(UserSchemaZod))
async getUsers(req: Request, res: Response) {
const users = await userService.findAll();
res.json(users);
}
// Using TypeBox
const UserSchemaTypeBox = Type.Object({
id: Type.Number(),
name: Type.String(),
email: Type.String()
});
@get('/users/:id')
@serialize(UserSchemaTypeBox)
async getUser(req: Request, res: Response) {
const user = await userService.findById(req.params.id);
res.json(user);
}
Multiple Response Schemas
You can define multiple response schemas for different status codes:
import { Request, Response, serialize } from 'balda';
import z from 'zod';
import { Type } from '@sinclair/typebox';
// Using Zod
@get('/users/:id')
@serialize(UserSchemaZod)
@serialize(z.object({ error: z.string() }), { status: 404 })
async getUser(req: Request, res: Response) {
const user = await userService.findById(req.params.id);
if (!user) {
return res.notFound({ error: 'User not found' });
}
res.json(user);
}
// Using TypeBox
@get('/users/:id')
@serialize(UserSchemaTypeBox)
@serialize(Type.Object({ error: Type.Literal('User not found') }), { status: 404 })
async getUserTypeBox(req: Request, res: Response) {
const user = await userService.findById(req.params.id);
if (!user) {
return res.notFound({ error: 'User not found' });
}
res.json(user);
}
Error Handling
Custom Error Responses
import { Request, Response } from 'balda';
@get('/users/:id')
async getUser(req: Request, res: Response) {
try {
const user = await userService.findById(req.params.id);
if (!user) {
return res.notFound({
error: 'User not found',
code: 'USER_NOT_FOUND'
});
}
res.json(user);
} catch (error) {
console.error('Error fetching user:', error);
res.internalServerError({
error: 'Failed to fetch user',
code: 'FETCH_ERROR'
});
}
}
Global Error Handler
import { Server } from "balda";
server.setErrorHandler((req, res, next, error) => {
console.error("Error:", error);
if (error.name === "ValidationError") {
return res.badRequest({
error: "Validation failed",
details: error.message,
});
}
if (error.name === "UnauthorizedError") {
return res.unauthorized({ error: "Authentication required" });
}
res.internalServerError({ error: "Internal server error" });
});
Request Lifecycle
Middleware Chain
import { Server, controller, get, post, middleware, validate } from "balda";
import { Request, Response } from "balda";
import z from "zod";
// 1. Global middleware
server.use(logger);
server.use(cors);
// 2. Controller middleware
@controller("/users")
@middleware(authMiddleware)
export class UsersController {
// 3. Route middleware
@get("/:id", { middleware: [rateLimit] })
// 4. Validation
@validate.params(z.object({ id: z.string() }))
// 5. Route handler
async getUser(req: Request, res: Response) {
// 6. Response serialization
res.json({ user: req.params.id });
}
}
TypeScript Support
Request Extensions
import { Request, Response } from 'balda';
interface AuthenticatedRequest extends Request {
user: {
id: number;
email: string;
role: string;
};
}
@get('/profile')
@middleware(authMiddleware)
async getProfile(req: AuthenticatedRequest, res: Response) {
// req.user is now typed
res.json({
id: req.user.id,
email: req.user.email,
role: req.user.role
});
}
Response Types
import { Request, Response } from 'balda';
interface ApiResponse<T> {
data: T;
message?: string;
timestamp: string;
}
@get('/users')
async getUsers(req: Request, res: Response) {
const users = await userService.findAll();
const response: ApiResponse<typeof users> = {
data: users,
message: 'Users retrieved successfully',
timestamp: new Date().toISOString()
};
res.json(response);
}
Best Practices
1. Consistent Response Format
// Good: Consistent structure
res.json({
success: true,
data: { id: 1, name: "John" },
message: "User created successfully",
});
// Error response
res.badRequest({
success: false,
error: "Validation failed",
details: ["Name is required"],
});
2. Proper Status Codes
// Use appropriate status codes
res.created({ id: 1 }); // 201 for new resources
res.noContent(); // 204 for successful deletion
res.badRequest({ error: "" }); // 400 for client errors
res.notFound({ error: "" }); // 404 for missing resources
3. Input Validation
@post('/users')
@validate.body(CreateUserSchema)
async createUser(req: Request, res: Response, body: CreateUser) {
// body is validated and typed
const user = await userService.create(body);
res.created(user);
}
4. Error Handling
@get('/users/:id')
async getUser(req: Request, res: Response) {
try {
const user = await userService.findById(req.params.id);
if (!user) {
return res.notFound({ error: 'User not found' });
}
res.json(user);
} catch (error) {
console.error('Database error:', error);
res.internalServerError({ error: 'Failed to fetch user' });
}
}
5. Security Headers
// Set security headers
res.setHeaders({
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
});
The request and response objects in Balda provide a powerful and intuitive API for building robust web applications with proper error handling, validation, and type safety.