Skip to main content

Overview

@apisr/controller provides a powerful, type-safe way to build API controllers with:
  • Type-safe handler functions with automatic payload validation
  • Flexible binding system for dependency injection
  • Built-in caching support with configurable strategies
  • Response handler integration for consistent API responses
  • Elysia.js integration for seamless framework adoption

Installation

npm install @apisr/controller

Peer Dependencies

package.json
{
  "peerDependencies": {
    "elysia": "^1.4.25",
    "keyv": "^5.6.0"
  }
}
Install elysia if using Elysia integration, and keyv if using caching features.

Quick Start

Create a Handler

import { createHandler } from "@apisr/controller";
import { z } from "zod";

const handler = createHandler({
  name: "myHandler",
  responseHandler: myResponseHandler,
});

// Define a handler with payload validation
const getUser = handler(
  async ({ payload, fail }) => {
    const user = await db.user.findUnique({
      where: { id: payload.userId },
    });

    if (!user) {
      throw fail("notFound", { resource: "User" });
    }

    return user;
  },
  {
    payload: z.object({
      userId: z.string(),
    }),
  }
);

// Call the handler
const result = await getUser({ userId: "123" });

Core Concepts

Handler Context

Every handler callback receives a context object with:
payload
T
Validated input data based on the payload schema
fail
(name, input?) => ErrorResponse
Create error responses from defined error handlers
cache
CacheFn
Cache function for storing/retrieving data
redirect
(to: string, returnType?) => any
Redirect helper function

Payload Validation

Validate incoming data with Zod schemas:
import { z } from "zod";

const createPost = handler(
  async ({ payload }) => {
    return await db.post.create({
      data: payload,
    });
  },
  {
    payload: z.object({
      title: z.string().min(1),
      content: z.string(),
      authorId: z.string(),
      tags: z.array(z.string()).optional(),
    }),
  }
);
The payload is automatically validated before your handler runs. Invalid data throws an error.

Sources Resolution

Payload validation supports multiple data sources:
const updateUser = handler(
  async ({ payload }) => {
    // payload.userId comes from params
    // payload.name comes from body
    return await db.user.update({
      where: { id: payload.userId },
      data: { name: payload.name },
    });
  },
  {
    payload: z.object({
      userId: z.from("params").string(),
      name: z.from("body").string(),
    }),
  }
);
Available sources:
  • params — URL path parameters
  • body — Request body
  • headers — HTTP headers
  • query — Query string parameters
  • handler.payload — Handler payload

Caching

Handler-Level Caching

Configure caching at the handler level:
const handler = createHandler({
  name: "cachedHandler",
  cache: {
    store: keyvStore,
    ttl: 60000, // 60 seconds
    key: (payload) => `user:${payload.userId}`,
    wrapHandler: true,
  },
});

const getUser = handler(
  async ({ payload }) => {
    // This will be cached automatically
    return await db.user.findUnique({
      where: { id: payload.userId },
    });
  },
  {
    payload: z.object({ userId: z.string() }),
  }
);

Call-Level Caching

Override caching per handler call:
const getUser = handler(
  async ({ payload, cache }) => {
    // Manual caching control
    return await cache(
      `user:${payload.userId}`,
      async () => {
        return await db.user.findUnique({
          where: { id: payload.userId },
        });
      }
    );
  },
  {
    payload: z.object({ userId: z.string() }),
    cache: {
      ttl: 30000, // Override TTL for this handler
    },
  }
);
cache.store
CacheStore
Cache storage backend (e.g., Keyv instance)
cache.ttl
number
Time-to-live in milliseconds
cache.key
string | (payload) => string
Cache key or key generator function
cache.wrapHandler
boolean
Automatically cache entire handler result

Bindings System

Bindings provide dependency injection for handlers:
import { createHandler, bindingsHelpers } from "@apisr/controller";

const handler = createHandler({
  name: "myHandler",
  bindings: (bind) => ({
    // Always inject current user
    currentUser: bind.alwaysInject({
      resolve: async ({ request }) => {
        const token = request.headers.get("authorization");
        return await getUserFromToken(token);
      },
    }),

    // Optionally inject database connection
    db: bind.inject({
      resolve: async ({ payload }) => {
        return createDbConnection(payload.dbName);
      },
    }),
  }),
});

const getUser = handler(
  async ({ payload, currentUser, db }) => {
    // currentUser is always available
    // db is available when specified in options
    return await db.user.findUnique({
      where: { id: payload.userId },
    });
  },
  {
    payload: z.object({ userId: z.string() }),
    db: { dbName: "main" }, // Enable db binding
  }
);

Binding Modes

bindings: (bind) => ({
  currentUser: bind.alwaysInject({
    resolve: async ({ request }) => {
      return await getUserFromToken(
        request.headers.get("authorization")
      );
    },
  }),
})
Always injected into every handler context.

Elysia Integration

Seamlessly integrate with Elysia.js:
import { Elysia } from "elysia";
import { createHandler } from "@apisr/controller/elysia";

const handler = createHandler({
  name: "elysiaHandler",
});

const getUser = handler(
  async ({ payload }) => {
    return await db.user.findUnique({
      where: { id: payload.userId },
    });
  },
  {
    payload: z.object({ userId: z.string() }),
  }
);

const app = new Elysia()
  .get("/users/:userId", async ({ params }) => {
    return await getUser(params);
  })
  .listen(3000);
Import from @apisr/controller/elysia for Elysia-specific features.

Error Handling

Use the fail function to create consistent error responses:
const handler = createHandler({
  name: "myHandler",
  responseHandler: createResponseHandler({})
    .defineError(
      "notFound",
      ({ input }) => ({
        message: `${input.resource} not found`,
        code: "NOT_FOUND",
      }),
      { status: 404 }
    ),
});

const getUser = handler(
  async ({ payload, fail }) => {
    const user = await db.user.findUnique({
      where: { id: payload.userId },
    });

    if (!user) {
      throw fail("notFound", { resource: "User" });
    }

    return user;
  },
  {
    payload: z.object({ userId: z.string() }),
  }
);

Direct vs Raw Mode

Handlers support two execution modes:
// Returns { data, error }
const result = await getUser({ userId: "123" });

if (result.error) {
  console.error(result.error);
} else {
  console.log(result.data);
}

Advanced Examples

Multi-Step Handler with Caching

const getPostWithAuthor = handler(
  async ({ payload, cache }) => {
    const post = await cache(
      `post:${payload.postId}`,
      async () => {
        return await db.post.findUnique({
          where: { id: payload.postId },
        });
      }
    );

    if (!post) {
      throw fail("notFound", { resource: "Post" });
    }

    const author = await cache(
      `user:${post.authorId}`,
      async () => {
        return await db.user.findUnique({
          where: { id: post.authorId },
        });
      }
    );

    return {
      ...post,
      author,
    };
  },
  {
    payload: z.object({ postId: z.string() }),
  }
);

Handler with Multiple Bindings

const handler = createHandler({
  name: "multiBindingHandler",
  bindings: (bind) => ({
    auth: bind.alwaysInject({
      resolve: async ({ request }) => {
        return await authenticate(request);
      },
    }),
    logger: bind.alwaysInject({
      resolve: () => createLogger(),
    }),
    db: bind.inject({
      resolve: () => createDbConnection(),
    }),
  }),
});

const createPost = handler(
  async ({ payload, auth, logger, db }) => {
    logger.info("Creating post", { userId: auth.user.id });

    const post = await db.post.create({
      data: {
        ...payload,
        authorId: auth.user.id,
      },
    });

    logger.info("Post created", { postId: post.id });
    return post;
  },
  {
    payload: z.object({
      title: z.string(),
      content: z.string(),
    }),
    db: true, // Enable db binding
  }
);

API Reference

createHandler

name
string
Handler name for debugging and caching
responseHandler
ResponseHandler
Response handler instance for consistent responses
bindings
(helpers) => Bindings
Function that defines available bindings
cache
CacheOptions
Default cache configuration for all handlers

Handler Options

payload
Schema
Zod schema for payload validation
cache
CacheOptions
Cache configuration for this handler
[bindingName]
any
Binding-specific options (when using inject mode)

Type Safety

@apisr/controller provides full TypeScript support:
  • Payload types are inferred from Zod schemas
  • Binding types are inferred from resolve functions
  • Error types are inferred from error definitions
  • Context types adapt based on enabled bindings
const getUser = handler(
  async ({ payload }) => {
    // payload is typed as { userId: string }
    payload.userId; // ✅ Type-safe
    payload.email; // ❌ Type error
  },
  {
    payload: z.object({ userId: z.string() }),
  }
);

Build docs developers (and LLMs) love