Skip to main content

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
}
Web API Compatibility

Balda's Request class provides compatibility with Web API Request objects through:

  • Request.fromRequest(webRequest): Converts a Web API Request to a Balda Request
  • req.toWebApi(): Converts a Balda Request back to a Web API Request
  • req.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;

// standard response handling
res.json({ userId, page, limit, userAgent, clientIP });

// shortcut for simple objects
return { userId, page, limit, userAgent, clientIP }
}

Type-Safe Request-Response

Balda provides automatic type inference for both request parameters and response bodies:

import { z } from "zod";

const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});

const ErrorSchema = z.object({
error: z.string(),
});

// Path parameters are inferred from the route path
// Response bodies are inferred from responses schemas
router.get(
"/users/:id",
{
responses: {
200: UserSchema,
404: ErrorSchema,
},
},
(req, res) => {
const { id } = req.params; // ✅ Typed as { id: string }

res.ok({ id, name: "John", email: "john@example.com" }); // ✅ Typed as { id: string; name: string; email: string }
res.notFound({ error: "User not found" }); // ✅ Typed as { error: string }

// res.ok({ wrong: 123 }); // ❌ Type error
},
);

// With validation — inline validation assigns typed data to `req.body`
const CreateUserSchema = z.object({ name: z.string(), email: z.string() });

router.post("/users", { body: CreateUserSchema }, (req, res) => {
// req.body is typed as z.infer<typeof CreateUserSchema>
res.created({ id: "123", ...req.body });
});

For controller-based routes, use the @validate decorator and manually type the Response generic:

@post('/:userId/posts')
@validate.body(CreatePostSchema)
@validate.query(PostQuerySchema)
async createPost(
req: Request<{ userId: string }>,
res: Response<{ 201: 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 }); // ✅ Typed as PostResponse
}

Note: req.body and req.query are untyped until validated. Inline route validation (via route options body/query/all) writes typed data to req.body/req.query, while the @validate decorators used on controller methods still inject validated arguments into the method signature.

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' });
}
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

Balda supports request validation using Zod, TypeBox, or plain JSON schemas. Validation is powered by AJV for performance, with schemas compiled once and cached for the server's lifetime.

Two Ways to Validate

1. Decorator-Based Validation (Controllers)

Use @validate decorators in controller classes. Validated data is automatically injected as additional handler parameters.

import { controller, post, 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),
});

@controller("/users")
export class UsersController {
@post("/")
@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 });
}
}

2. Inline Validation (Direct Routes)

For inline route definitions, use the body, query, or all options. Validated data is written back to the request object (req.body and req.query), so your handler can remain (req, res) and access typed values directly.

import { Server } from "balda";
import { z } from "zod";

const server = new Server();

const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(18),
});

// Validate request body — validated data is written to `req.body`
server.router.post(
"/users",
{
body: CreateUserSchema,
responses: {
201: UserResponseSchema,
},
},
(req, res) => {
// req.body is typed as z.infer<typeof CreateUserSchema>
const { name, email, age } = req.body;
res.created({ name, email, age });
},
);

// Validate query parameters — validated data is written to `req.query`
const PaginationSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
});

router.get(
"/users",
{
query: PaginationSchema,
},
(req, res) => {
const { page, limit } = req.query;
res.json({ users: [], page, limit });
},
);

// Validate both body and query — both `req.body` and `req.query` are typed
router.post(
"/search",
{
body: SearchBodySchema,
query: SearchQuerySchema,
},
(req, res) => {
res.json({ results: [], body: req.body, query: req.query });
},
);

// Validate all (body + query merged) — merged validated object is written to `req.body`
router.post(
"/filter",
{
all: FilterSchema, // Merges body and query into single validated object
},
(req, res) => {
// req.body contains merged body + query
res.json({ filtered: req.body });
},
);

Parameter Order:

  • Inline validation: handler signature is (req, res); validated data is available on req.body and/or req.query.
  • Decorator-based validation (@validate): validated data is injected as additional method parameters (legacy behavior).

By default, validation throws errors on invalid data and returns 400 responses. Validation happens automatically before your handler executes.

Built-in Validation Features

Zod is a peer dependency loaded only when Zod schemas are used. Schemas are compiled once and cached during the server lifecycle—no compilation overhead on subsequent requests. Zod4 is required with the toJSONSchema() method.

Query Parameter Validation

import { controller, get, validate } from "balda";
import { z } from "zod";

const QuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
});

@controller("/users")
export class UsersController {
@get("/")
@validate.query(QuerySchema)
async getUsers(
req: Request,
res: Response,
query: z.infer<typeof QuerySchema>,
) {
const { page, limit } = query;
res.json({ users: [], page, limit });
}
}

Or with inline routes:

router.get(
"/users",
{
query: QuerySchema,
},
(req, res, validatedQuery) => {
const { page, limit } = validatedQuery;
res.json({ users: [], page, limit });
},
);

Combined Body + Query Validation

// Decorator approach
@post('/search')
@validate.body(SearchBodySchema)
@validate.query(SearchQuerySchema)
async search(req: Request, res: Response, body: SearchBody, query: SearchQuery) {
// Both injected in order
}

// Inline approach
router.post('/search', {
body: SearchBodySchema,
query: SearchQuerySchema
}, (req, res, validatedBody, validatedQuery) => {
// Both injected in order
res.json({ results: [] });
});

// Or validate all (body + query merged)
router.post('/filter', {
all: FilterSchema // Merges body and query
}, (req, res, validatedAll) => {
res.json({ filtered: validatedAll });
});

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 Caching

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.

Using Plain JSON Schemas

Plain JSON schema objects are supported for validation and — unlike Zod or TypeBox — require no extra library. For full TypeScript type inference, define your schema as const so TypeScript preserves literal types. Balda uses json-schema-to-ts internally to derive the TypeScript type from the schema.

Example

const bodySchema = {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
} as const;

router.post("/", { body: bodySchema }, async (req, res) => {
return res.json({ name: req.body.name });
});
as const is required

Without as const, TypeScript widens the schema to a generic object type and inference falls back to unknown. Always annotate your plain JSON schemas with as const. Also, you need to install json-schema-to-ts to be able to use plain JSON schemas with type inference.

npm install json-schema-to-ts --save-dev

Decorator-based validation

import { controller, post, validate } from "balda";

const CreateUserSchema = {
type: "object",
properties: {
name: { type: "string", minLength: 1 },
email: { type: "string", format: "email" },
age: { type: "integer", minimum: 18 },
},
required: ["name", "email", "age"],
additionalProperties: false,
} as const; // ← required for type inference

@controller("/users")
export class UsersController {
@post("/")
@validate.body(CreateUserSchema)
async createUser(
req: Request,
res: Response,
body: FromSchema<typeof CreateUserSchema>, // { name: string; email: string; age: number }
) {
const { name, email, age } = body; // ✅ fully typed
res.created({ name, email, age });
}
}

Inline route validation

import { Server } from "balda";

const CreateUserSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer", minimum: 18 },
},
required: ["name", "age"],
additionalProperties: false,
} as const;

server.router.post("/users", { body: CreateUserSchema }, (req, res) => {
// req.body is typed as { name: string; age: number }
const { name, age } = req.body; // ✅ typed
res.created({ name, age });
});
Performance & Caching

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.

Schema Libraries

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.

Synchronous Validation Only

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);
Required Configuration

The following AJV options are required and must not be changed:

  • validateSchema: false - Required for proper Zod schema compilation
  • strict: false - Required for proper Zod schema compilation

Changing these values will cause validation errors and break Zod schema support.

When to Customize AJV

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

Response schemas enable both type safety and fast JSON serialization. When you define schemas in responses, Balda automatically uses fast-json-stringify for high-performance serialization — no @serialize decorator needed.

Inline Route Schemas

import { z } from "zod";

const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
});

// Schemas provide both type safety AND fast serialization
router.get(
"/users",
{
responses: {
200: z.array(UserSchema),
},
},
(req, res) => {
const users = await userService.findAll();
res.ok(users); // ✅ Typed as { id: number; name: string; email: string }[]
// ✅ Serialized with fast-json-stringify
},
);

Decorator-based Serialization

For controller-based routes, use the @serialize decorator to enable fast serialization Since decorators cannot modify the function signature, the validated data is injected as an additional parameter.

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);
}

Fast JSON Serialization

Balda automatically uses fast-json-stringify for high-performance JSON serialization when response schemas are available. This can be 2-5x faster than JSON.stringify() for complex objects.

When fast-json-stringify is Used

Scenariofast-json-stringifyStandard JSON.stringify
responses schemas in route options
@serialize(schema) decorator present
@serialize(schema, { throwErrorOnValidationFail: true })
No schemas defined

How It Works

// ✅ Uses fast-json-stringify (schema in responses)
router.get(
'/users',
{ responses: { 200: UserSchema } },
(req, res) => {
res.ok(users); // Serialized with fast-json-stringify + type-safe
},
);

// ✅ Uses fast-json-stringify (schema provided via decorator)
@get('/users')
@serialize(UserSchema)
async getUsers(req: Request, res: Response) {
res.json(users); // Serialized with fast-json-stringify
}

// ❌ Uses standard JSON.stringify (no schema)
router.get('/health', (req, res) => {
res.json({ status: 'ok' }); // Serialized with JSON.stringify
});

Validation Mode

The @serialize decorator has a throwErrorOnValidationFail option that controls validation behavior:

// Validation disabled (default): No validation, uses fast-json-stringify for serialization
@serialize(UserSchema) // equivalent to @serialize(UserSchema, { throwErrorOnValidationFail: false })

// Validation enabled: Validates response against schema, then uses fast-json-stringify
@serialize(UserSchema, { throwErrorOnValidationFail: true })
ModeValidationfast-json-stringifyUse Case
throwErrorOnValidationFail: false (default)Production - maximum performance
throwErrorOnValidationFail: trueDevelopment - catch schema mismatches
Performance Recommendation

Use the default mode (throwErrorOnValidationFail: false) in production for best performance. The schema is still used for fast serialization, but validation overhead is skipped.

Use throwErrorOnValidationFail: true during development to catch any mismatches between your response data and schema.

Schema Caching

All serializers are compiled once and cached for the lifetime of the server:

// First request: compiles fast-json-stringify serializer and caches it
// All subsequent requests: reuses cached serializer ⚡

@get('/users')
@serialize(UserSchema)
async getUsers(req: Request, res: Response) {
res.json(users); // Uses cached serializer
}

You can monitor cache statistics for debugging:

import { getSerializerCacheStats } from "balda";

const stats = getSerializerCacheStats();
console.log(`Cached serializers: ${stats.size}`);
console.log(`Schema refs created: ${stats.schemaRefsCreated}`);
Schema Support

fast-json-stringify works with all schema types:

  • Zod schemas: Converted to JSON Schema, then compiled
  • TypeBox schemas: Already JSON Schema compliant, compiled directly
  • Plain JSON schemas: Compiled directly

All schemas benefit from the same caching mechanism.

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,
cors,
} from "balda";
import { Request, Response } from "balda";
import z from "zod";
import { authMiddleware } from "../auth/auth.md";

// 1. Global middleware
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

When using inline route registration with responses, each response shorthand method is automatically typed to its status code:

import { z } from "zod";
import { Type } from "@sinclair/typebox";

// Zod schemas — response types are inferred automatically
router.get(
"/users/:id",
{
responses: {
200: z.object({ id: z.string(), name: z.string() }),
404: z.object({ error: z.string() }),
},
},
(req, res) => {
res.ok({ id: "1", name: "John" }); // ✅ Typed to 200 schema
res.notFound({ error: "Not found" }); // ✅ Typed to 404 schema
// res.ok({ wrong: true }); // ❌ Type error
},
);

// TypeBox schemas — same inference
router.get(
"/posts",
{
responses: {
200: Type.Array(Type.Object({ title: Type.String() })),
},
},
(req, res) => {
res.ok([{ title: "Hello" }]); // ✅ Typed as { title: string }[]
},
);

// Plain JSON Schema — resolves to `any` (no TS type representation)
router.get(
"/health",
{
responses: {
200: { type: "object", properties: { status: { type: "string" } } },
},
},
(req, res) => {
res.ok({ status: "ok" }); // ✅ No type constraints (any)
},
);

For controllers, you can manually type the Response generic with a status-code-to-type map:

import { Request, Response } from 'balda';

interface User { id: string; name: string; email: string }
interface ApiError { error: string }

@get('/users/:id')
async getUser(
req: Request<{ id: string }>,
res: Response<{ 200: User; 404: ApiError }>
) {
res.ok({ id: "1", name: "John", email: "john@example.com" }); // ✅ Typed as User
res.notFound({ error: "User not found" }); // ✅ Typed as ApiError
}
Supported Schema Types
Schema TypeType InferenceSerialization
Zod✅ Automatic✅ fast-json-stringify
TypeBox✅ Automatic✅ fast-json-stringify
JSON Schema❌ Resolves to any✅ fast-json-stringify

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.