Skip to main content

Overview

@apisr/response provides a unified way to create consistent, type-safe API responses with:
  • JSON responses with automatic validation and transformation
  • Error responses with customizable error handlers
  • Binary responses for files and streams
  • Text responses for plain text
  • Meta support for response metadata
  • Type-safe error definitions with payload schemas

Installation

npm install @apisr/response

Quick Start

Create a Response Handler

import { createResponseHandler, options } from "@apisr/response";
import { z } from "zod";

const response = createResponseHandler(
  options()
    .json({
      mapData: (data) => ({
        result: data,
        timestamp: new Date().toISOString(),
      }),
    })
    .meta({
      schema: z.object({
        requestId: z.string(),
        version: z.string(),
      }),
      default: () => ({
        requestId: crypto.randomUUID(),
        version: "1.0.0",
      }),
    })
    .build()
);

Send JSON Responses

// Simple JSON response
const res = response.json({ message: "Hello, world!" });

// With custom status
const created = response.json(
  { id: 123, name: "New Item" },
  { status: 201 }
);

Handle Errors

// Define custom errors
const response = createResponseHandler({})
  .defineError(
    "notFound",
    ({ input }) => ({
      message: `${input.resource} not found`,
      code: "NOT_FOUND",
    }),
    {
      status: 404,
      input: z.object({ resource: z.string() }),
    }
  )
  .defineError(
    "validation",
    ({ input }) => ({
      message: "Validation failed",
      errors: input.errors,
      code: "VALIDATION_ERROR",
    }),
    {
      status: 400,
      input: z.object({ errors: z.array(z.string()) }),
    }
  );

// Throw errors
throw response.fail("notFound", { resource: "User" });
throw response.fail("validation", {
  errors: ["Email is required", "Password too short"],
});

Core Concepts

Response Types

const res = response.json(
  { message: "Success", data: [1, 2, 3] },
  {
    status: 200,
    headers: { "X-Custom": "value" },
  }
);

Options Builder

Use the options builder for type-safe configuration:
import { options } from "@apisr/response";

const response = createResponseHandler(
  options()
    .json({
      mapData: (data) => ({
        success: true,
        data,
      }),
    })
    .error({
      mapError: ({ error, parsedError }) => {
        if (parsedError) return parsedError;
        return {
          message: error?.message ?? "Unknown error",
          code: "INTERNAL_ERROR",
        };
      },
    })
    .meta({
      schema: z.object({
        timestamp: z.string(),
      }),
      default: () => ({
        timestamp: new Date().toISOString(),
      }),
    })
    .build()
);

JSON Responses

Data Mapping

Transform response data before sending:
const response = createResponseHandler({
  json: {
    mapData: (data) => ({
      success: true,
      result: data,
      timestamp: Date.now(),
    }),
  },
});

// Input: { message: "Hello" }
// Output: { success: true, result: { message: "Hello" }, timestamp: 1234567890 }
const res = response.json({ message: "Hello" });

Custom Headers

const response = createResponseHandler({
  json: {
    headers: (ctx) => ({
      "X-Data-Type": typeof ctx.output,
      "X-Timestamp": new Date().toISOString(),
    }),
  },
});

const res = response.json({ data: [1, 2, 3] });
// Includes custom headers automatically

Error Handling

Define Custom Errors

const response = createResponseHandler({})
  .defineError(
    "unauthorized",
    () => ({
      message: "Authentication required",
      code: "UNAUTHORIZED",
    }),
    { status: 401 }
  )
  .defineError(
    "forbidden",
    ({ input }) => ({
      message: `Access denied: ${input.reason}`,
      code: "FORBIDDEN",
    }),
    {
      status: 403,
      input: z.object({ reason: z.string() }),
    }
  )
  .defineError(
    "rateLimit",
    ({ input }) => ({
      message: "Rate limit exceeded",
      retryAfter: input.retryAfter,
      code: "RATE_LIMIT",
    }),
    {
      status: 429,
      input: z.object({ retryAfter: z.number() }),
    }
  );

Default Errors

Built-in default errors are available:
throw response.fail("internal", {
  cause: new Error("Database connection failed"),
});
500 Internal Server Error

Error Mapping

const response = createResponseHandler({
  error: {
    mapError: ({ error, parsedError, meta }) => {
      // Custom error transformation
      if (parsedError) {
        return {
          ...parsedError.output,
          meta,
        };
      }

      // Handle unexpected errors
      return {
        message: "An unexpected error occurred",
        code: "UNKNOWN",
        meta,
      };
    },
  },
});

Binary Responses

Send files, blobs, or streams:
// Send a file
const file = await Bun.file("./document.pdf");
const res = response.binary(file, {
  headers: {
    "Content-Type": "application/pdf",
    "Content-Disposition": 'attachment; filename="document.pdf"',
  },
});

// Send a blob
const blob = new Blob(["Hello, world!"], { type: "text/plain" });
const res = response.binary(blob);

// Send an ArrayBuffer
const buffer = new Uint8Array([1, 2, 3, 4]);
const res = response.binary(buffer);

// Send a stream
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue(new TextEncoder().encode("chunk 1"));
    controller.enqueue(new TextEncoder().encode("chunk 2"));
    controller.close();
  },
});
const res = response.binary(stream);

Binary Data Transformation

const response = createResponseHandler({
  binary: {
    mapData: (binary) => {
      // Transform or validate binary data
      if (binary instanceof Blob && binary.size > 10_000_000) {
        throw new Error("File too large");
      }
      return binary;
    },
    headers: (binary) => ({
      "Content-Length": String(binary instanceof Blob ? binary.size : 0),
    }),
  },
});

Metadata

Attach metadata to responses:
const response = createResponseHandler({
  meta: {
    schema: z.object({
      requestId: z.string(),
      userId: z.string().optional(),
      timestamp: z.string(),
    }),
    default: () => ({
      requestId: crypto.randomUUID(),
      timestamp: new Date().toISOString(),
    }),
  },
});

// Add metadata to specific responses
const withMeta = response.withMeta({
  userId: "user-123",
});

const res = withMeta.json({ message: "Hello" });
// Response includes requestId, userId, and timestamp in metadata

Response Mapping

Customize the final response structure:
const response = createResponseHandler({
  mapResponse: ({ data, error, response }) => {
    // Custom response structure
    if (error) {
      return new Response(
        JSON.stringify({
          ok: false,
          error: error,
        }),
        {
          status: response.status,
          headers: response.headers,
        }
      );
    }

    return new Response(
      JSON.stringify({
        ok: true,
        data: data,
      }),
      {
        status: 200,
        headers: response.headers,
      }
    );
  },
});

Default Response Format

By default, responses use 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": { "message": "Hello" },
  "metadata": { "requestId": "...", "timestamp": "..." }
}
Error response:
{
  "success": false,
  "error": {
    "message": "User not found",
    "code": "NOT_FOUND"
  },
  "data": null,
  "metadata": { "requestId": "...", "timestamp": "..." }
}

Advanced Examples

Comprehensive Response Handler

import { createResponseHandler, options } from "@apisr/response";
import { z } from "zod";

const response = createResponseHandler(
  options()
    .json({
      mapData: (data) => ({
        result: data,
        serverTime: new Date().toISOString(),
      }),
      headers: () => ({
        "X-API-Version": "2.0",
      }),
    })
    .error({
      mapDefaultError: (type) => {
        const errors = {
          internal: { message: "Server error", code: "SERVER_ERROR" },
          badRequest: { message: "Bad request", code: "BAD_REQUEST" },
          notFound: { message: "Not found", code: "NOT_FOUND" },
        };
        return errors[type] || errors.internal;
      },
    })
    .meta({
      schema: z.object({
        requestId: z.string(),
        environment: z.enum(["dev", "prod"]),
      }),
      default: () => ({
        requestId: crypto.randomUUID(),
        environment: process.env.NODE_ENV === "production" ? "prod" : "dev",
      }),
    })
    .binary({
      headers: (binary) => {
        const size = binary instanceof Blob ? binary.size : 0;
        return {
          "Content-Length": String(size),
          "Cache-Control": "public, max-age=3600",
        };
      },
    })
    .build()
)
  .defineError(
    "validation",
    ({ input }) => ({
      message: "Validation failed",
      fields: input.fields,
      code: "VALIDATION_ERROR",
    }),
    {
      status: 400,
      input: z.object({
        fields: z.record(z.string()),
      }),
    }
  )
  .defineError(
    "unauthorized",
    () => ({
      message: "Authentication required",
      code: "UNAUTHORIZED",
    }),
    { status: 401 }
  );

API Reference

createResponseHandler

options
Options
Response handler configuration object

ResponseHandler Methods

json
(data, options?) => JsonResponse
Create a JSON response
text
(text, options?) => TextResponse
Create a plain text response
binary
(binary, options?) => BinaryResponse
Create a binary response (Blob, ArrayBuffer, etc.)
fail
(name, input?) => ErrorResponse
Create an error response
withMeta
(meta) => ResponseHandler
Create new handler with preassigned metadata
defineError
(name, handler, options?) => ResponseHandler
Define a custom error handler

Options Builder Methods

json
(config) => Builder
Configure JSON response options
error
(config) => Builder
Configure error handling options
meta
(config) => Builder
Configure metadata options
binary
(config) => Builder
Configure binary response options
build
() => Options
Build the final options object

Type Safety

@apisr/response provides full TypeScript support:
  • Error types are inferred from defineError calls
  • Payload schemas enforce valid error inputs
  • Response types adapt based on configuration
  • Meta schemas validate metadata structure
const response = createResponseHandler({}).defineError(
  "notFound",
  ({ input }) => ({
    message: input.resource, // ✅ Type-safe
  }),
  {
    input: z.object({ resource: z.string() }),
  }
);

// ✅ Type-safe
throw response.fail("notFound", { resource: "User" });

// ❌ Type error
throw response.fail("notFound", { invalid: "field" });

Build docs developers (and LLMs) love