Skip to content

Capabilities

The capability() function is the core primitive. This document covers every overload, option, and method.

Basic usage

ts
import { capability } from '@capixjs/core';
import { z } from 'zod';

// No schema — accepts any input, typed as unknown
const ping = capability(() => ({ ok: true }));

// With input schema — validates and types input
const greet = capability(
  z.object({ name: z.string() }),
  ({ name }) => `Hello, ${name}`,
);

// With explicit intent
const searchPosts = capability(
  z.object({ q: z.string(), limit: z.coerce.number().default(20) }),
  ({ q, limit }) => db.posts.search(q, limit),
  'query',
);

Signature

ts
capability(resolver)
capability(inputSchema, resolver)
capability(inputSchema, resolver, intent)
ParameterTypeDescription
inputSchemaZodSchema | nullOptional Zod schema. Omit for no validation.
resolver(input, ctx) => output | Promise<output>The resolver function
intentIntent | undefinedExplicit intent. Inferred from name when omitted.

Intent

Intent determines how the REST transport maps the capability to an HTTP route.

IntentHTTP methodWhen inferred
'query'GETget*, list*, find*, fetch*, read*, search*, filter*, all*, me, status, health, count, check
'mutation'POSTcreate*, add*, new*, and anything else
'update'PATCHupdate*, edit*, patch*, modify*
'replace'PUTreplace*, set*, put*
'delete'DELETEdelete*, remove*, destroy*, cancel*

Pass intent explicitly when the name does not follow these conventions:

ts
// 'register' would infer 'mutation' but also uses POST — fine
// 'sync' would infer 'mutation' — correct, no need to override
// 'export' would infer 'mutation' — but if you want GET:
const exportData = capability(schema, handler, 'query');

Context-typed factory

capability.withContext<TContext>() returns a factory pre-bound to your context type. Define it once per application:

ts
// src/capabilities.ts
import { capability } from '@capixjs/core';
import type { AppContext } from './context.js';

export const cap = capability.withContext<AppContext>();

Use it everywhere:

ts
// src/capabilities/users/get.ts
import { cap } from '../../capabilities.js';

export const getUser = cap(
  z.object({ id: z.string() }),
  async ({ id }, ctx) => {
    // ctx.user, ctx.db — all typed correctly
    const user = await ctx.db.users.findById(id);
    if (!user) throw errors.NotFound();
    return user;
  },
  'query',
).guard(mustBeUser);

Without withContext, ctx is typed as BaseContext = { requestId: string }.

The two-factory pattern

TypeScript cannot retroactively narrow the resolver's ctx type based on guards added via .guard(). The workaround is a second factory with the narrowed context type:

ts
type AppContext  = { requestId: string; user: User | null; db: Database };
type AuthContext = AppContext & { user: User }; // user is non-null

export const cap     = capability.withContext<AppContext>();   // public endpoints
export const authCap = capability.withContext<AuthContext>();  // authenticated endpoints
ts
// ctx.user is User (non-null) — no null check needed in the resolver
export const getProfile = authCap(
  z.object({}),
  async (_, ctx) => ctx.user, // ✓ typed correctly
  'query',
).guard(mustBeUser);

The guard still runs at runtime. The factory only affects TypeScript types. Always pair authCap with .guard(mustBeUser). See TypeScript workarounds for the full explanation.

Output schema

.output(schema) validates the resolver's return value in development mode and generates the GraphQL type in the GraphQL transport:

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

const getUser = cap(
  z.object({ id: z.string() }),
  async ({ id }) => db.users.find(id),
  'query',
).output(UserSchema);

In production (NODE_ENV=production), output validation is skipped for performance.

Guards

.guard(guard) appends a guard to the capability's guard list. Guards run in order:

ts
const getSecret = cap(schema, handler)
  .guard(mustBeLoggedIn)  // runs first
  .guard(mustBeAdmin);    // runs second, only if first passes

Each .guard() call returns a new capability. The original is unchanged.

Input guards

.inputGuard(guard) adds a guard that runs after input validation, receiving (input, ctx):

ts
const mustOwnPost = defineInputGuard((input: { id: string }, ctx) => {
  if (!ctx.user) throw errors.Unauthorized();
  const post = ctx.db.posts.find(input.id);
  if (post?.authorId !== ctx.user.id) throw errors.Forbidden();
});

const updatePost = cap(
  z.object({ id: z.string(), title: z.string() }),
  handler,
  'update',
).inputGuard(mustOwnPost);

Enhancers

.enhance(enhancer) wraps the resolver:

ts
const cachedGetUser = cap(schema, handler, 'query')
  .guard(mustBeUser)
  .enhance(withCache(30))
  .enhance(withTimeout(5000));

Internal composition with .resolve()

.resolve(input, ctx) invokes the capability's guards and resolver directly from inside another capability:

ts
const getOrder = cap(z.object({ id: z.string() }), async ({ id }, ctx) => {
  const order = await ctx.db.orders.find(id);
  if (!order) throw errors.NotFound();
  return order;
}, 'query').guard(mustBeUser);

const cancelOrder = cap(z.object({ id: z.string() }), async ({ id }, ctx) => {
  const order = await getOrder.resolve({ id }, ctx); // guards re-run
  if (order.status !== 'pending') throw errors.Conflict({ reason: 'Cannot cancel' });
  return ctx.db.orders.cancel(id);
}, 'mutation').guard(mustBeUser);

Guards always re-run when calling .resolve(). This is correct — the inner capability's access rules always apply, regardless of where it's called from.

Immutability

Every method returns a new capability. The original is unchanged:

ts
const base = cap(schema, handler);
const withAuth = base.guard(mustBeUser);       // new capability
const withAuthAndCache = withAuth.enhance(withCache(30)); // new capability

// base, withAuth, withAuthAndCache are all distinct

This makes it safe to share base capabilities across your codebase and apply different guards or enhancers for different contexts.

Capability types

For TypeScript utilities, InferInput<Cap>, InferOutput<Cap>, and InferContext<Cap> extract the capability's type parameters:

ts
import type { InferInput, InferOutput } from '@capixjs/core';

type GetUserInput  = InferInput<typeof getUser>;   // { id: string }
type GetUserOutput = InferOutput<typeof getUser>;  // User

Released under the MIT License.