Overview
@apisr/response provides a type-safe, schema-driven approach to handling API responses with:
- Unified response format across all endpoints
- Type-safe error definitions with custom error types
- Automatic response mapping with JSON, text, and binary support
- Meta information injection for tracking and debugging
- Custom response transformations
Installation
bun add @apisr/response @apisr/schema @apisr/zod
Basic Setup
Create a response handler
Define your response structure using createResponseHandler:import { createResponseHandler } from "@apisr/response";
import { z } from "@apisr/zod";
const responseHandler = createResponseHandler((options) => ({
json: options.json({
schema: z.object({
data: z.any(),
type: z.string(),
}),
}),
}));
Define custom errors
Add custom error types to your response handler:const responseHandler = createResponseHandler((options) => ({
json: options.json({
schema: z.object({
data: z.any(),
}),
}),
error: {
schema: z.object({
code: z.string(),
message: z.string(),
}),
},
}))
.defineError("notFound", {
code: "NOT_FOUND",
message: "Resource not found",
})
.defineError("unauthorized", {
code: "UNAUTHORIZED",
message: "Authentication required",
});
Use the response handler
Generate responses in your API handlers:// Success response
const response = responseHandler.json({ data: { id: 1, name: "John" } });
// Error response
const errorResponse = responseHandler.fail("notFound");
Response Types
JSON Responses
Create JSON responses with automatic serialization:
const response = responseHandler.json(
{
users: [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
},
{
status: 200,
headers: {
"Cache-Control": "max-age=3600",
},
}
);
Text Responses
Create plain text responses:
const response = responseHandler.text("Hello, World!", {
status: 200,
headers: {
"Content-Type": "text/plain",
},
});
Binary Responses
Create binary responses for files and streams:
const blob = new Blob([data], { type: "application/pdf" });
const response = responseHandler.binary(blob, {
status: 200,
headers: {
"Content-Disposition": 'attachment; filename="document.pdf"',
},
});
Binary responses support:
Blob
ArrayBuffer
Uint8Array
ReadableStream
Error Handling
Defining Error Types
Static Errors
Define errors with fixed output:
const responseHandler = createResponseHandler((options) => ({
json: options.json({ schema: z.object({ data: z.any() }) }),
error: {
schema: z.object({
code: z.string(),
message: z.string(),
status: z.number(),
}),
},
}))
.defineError("notFound", {
code: "NOT_FOUND",
message: "The requested resource was not found",
status: 404,
})
.defineError("serverError", {
code: "INTERNAL_ERROR",
message: "An internal server error occurred",
status: 500,
});
Dynamic Errors
Define errors that accept input:
const responseHandler = createResponseHandler((options) => ({
json: options.json({ schema: z.object({ data: z.any() }) }),
error: {
schema: z.object({
code: z.string(),
message: z.string(),
field: z.string().optional(),
}),
},
}))
.defineError(
"validationError",
({ input }) => ({
code: "VALIDATION_ERROR",
message: `Validation failed: ${input.reason}`,
field: input.field,
}),
{
input: z.object({
field: z.string(),
reason: z.string(),
}),
}
);
// Usage
const error = responseHandler.fail("validationError", {
field: "email",
reason: "Invalid email format",
});
Access meta information in error handlers:
const responseHandler = createResponseHandler((options) => ({
json: options.json({ schema: z.object({ data: z.any() }) }),
meta: {
schema: z.object({
requestId: z.string(),
timestamp: z.number(),
}),
},
error: {
schema: z.object({
code: z.string(),
message: z.string(),
requestId: z.string(),
}),
},
}))
.defineError(
"trackedError",
({ meta, input }) => ({
code: "TRACKED_ERROR",
message: input.message,
requestId: meta.requestId, // Access meta
}),
{
input: z.object({
message: z.string(),
}),
}
);
Error Response Options
Customize error status codes and text:
const responseHandler = createResponseHandler((options) => ({
json: options.json({ schema: z.object({ data: z.any() }) }),
error: {
schema: z.object({
code: z.string(),
message: z.string(),
}),
},
}))
.defineError(
"paymentRequired",
{
code: "PAYMENT_REQUIRED",
message: "Payment is required to access this resource",
},
{
status: 402,
statusText: "Payment Required",
}
);
Default Error Types
Built-in error types are automatically available:
// Throws 401 Unauthorized
throw responseHandler.fail("unauthorized");
// Throws 403 Forbidden
throw responseHandler.fail("forbidden");
// Throws 404 Not Found
throw responseHandler.fail("notFound");
// Throws 400 Bad Request
throw responseHandler.fail("badRequest");
// Throws 409 Conflict
throw responseHandler.fail("conflict");
// Throws 429 Too Many Requests
throw responseHandler.fail("tooMany");
// Throws 500 Internal Server Error
throw responseHandler.fail("internal", { cause: error });
Add metadata to all responses:
const responseHandler = createResponseHandler((options) => ({
json: options.json({
schema: z.object({ data: z.any() }),
}),
meta: {
schema: z.object({
requestId: z.string(),
timestamp: z.number(),
version: z.string(),
}),
default: () => ({
requestId: crypto.randomUUID(),
timestamp: Date.now(),
version: "v1.0.0",
}),
},
}));
Create response handlers with pre-set meta:
const requestHandler = responseHandler.withMeta({
requestId: "abc-123",
userId: "user-456",
});
// All responses will include the preassigned meta
const response = requestHandler.json({ data: { message: "Hello" } });
Use .withMeta() for request-scoped metadata like request IDs, user context, or trace information.
Custom JSON Mapping
Transform JSON data before serialization:
const responseHandler = createResponseHandler((options) => ({
json: options.json({
schema: z.object({
result: z.any(),
count: z.number(),
}),
mapData: (input) => ({
result: input,
count: Array.isArray(input) ? input.length : 1,
}),
}),
}));
// Input: [{ id: 1 }, { id: 2 }]
// Output: { result: [{ id: 1 }, { id: 2 }], count: 2 }
const response = responseHandler.json([{ id: 1 }, { id: 2 }]);
const responseHandler = createResponseHandler((options) => ({
binary: options.binary({
mapData: (input) => {
// Add watermark or transform binary data
return addWatermark(input);
},
}),
}));
Custom Error Mapping
Transform error output globally:
const responseHandler = createResponseHandler((options) => ({
json: options.json({ schema: z.object({ data: z.any() }) }),
error: {
schema: z.object({
error: z.string(),
details: z.string(),
}),
mapDefaultError: (defaultError) => ({
error: defaultError.name,
details: defaultError.message,
}),
},
}));
Custom Response Mapping
Override the default response structure:
const responseHandler = createResponseHandler((options) => ({
json: options.json({
schema: z.object({ data: z.any() }),
}),
error: {
schema: z.object({ error: z.string() }),
},
mapResponse: ({ data, error, response }) => {
// Custom response structure
if (error) {
return new Response(
JSON.stringify({
success: false,
error: error,
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
return new Response(
JSON.stringify({
success: true,
payload: data,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
},
}));
Add headers to all responses:
const responseHandler = createResponseHandler((options) => ({
json: options.json({
schema: z.object({ data: z.any() }),
headers: {
"X-API-Version": "1.0",
"X-Rate-Limit": "100",
},
}),
}));
Generate headers based on response data:
const responseHandler = createResponseHandler((options) => ({
json: options.json({
schema: z.object({
data: z.any(),
total: z.number(),
}),
headers: ({ output }) => ({
"X-Total-Count": String(output.total),
"X-Page-Size": "20",
}),
}),
}));
const responseHandler = createResponseHandler((options) => ({
json: options.json({ schema: z.object({ data: z.any() }) }),
error: {
schema: z.object({ code: z.string() }),
headers: (error) => ({
"X-Error-Code": error.code,
"Retry-After": error.code === "RATE_LIMIT" ? "60" : undefined,
}),
},
}));
By default, responses follow this structure:
interface DefaultResponse<TData = unknown> {
success: boolean;
error: ErrorResponse | null;
data: TData | null;
metadata: Record<string, unknown>;
}
Success response:
{
"success": true,
"error": null,
"data": { "id": 1, "name": "John" },
"metadata": {
"requestId": "abc-123",
"timestamp": 1234567890
}
}
Error response:
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "Resource not found"
},
"data": null,
"metadata": {
"requestId": "abc-123",
"timestamp": 1234567890
}
}
Best Practices
Consistent error schemasDefine a consistent error schema across your entire API:error: {
schema: z.object({
code: z.string(),
message: z.string(),
details: z.record(z.any()).optional(),
timestamp: z.number(),
}),
}
Use meta for debuggingInclude request IDs and timing information in meta:meta: {
schema: z.object({
requestId: z.string(),
duration: z.number(),
timestamp: z.number(),
}),
default: () => ({
requestId: crypto.randomUUID(),
timestamp: Date.now(),
duration: 0, // Calculate in middleware
}),
}
Avoid exposing sensitive dataNever include sensitive information (passwords, tokens, internal IDs) in error messages or metadata that might be logged or sent to clients.
Type-safe error handlingLeverage TypeScript’s type inference:const result = await handler();
if (result.error) {
// result.error is fully typed based on your error schema
console.error(result.error.code, result.error.message);
} else {
// result.data is fully typed based on your success schema
console.log(result.data);
}