Katman

Middleware

Guards and wraps — the two kinds of middleware in Katman, when to use each, and how they execute.

Middleware lets you run code before (and sometimes after) a procedure. Katman has two kinds: guards and wraps. They solve different problems, so understanding the difference is important.

Guards

A guard is a function that receives the current context and either:

  1. Returns an object — its properties get merged into ctx for everything that runs after it.
  2. Returns nothing — it acts as a pure check (throw an error to reject the request).
  3. Throws — the request stops immediately and the client gets an error.
import { ,  } from "katman"

const  = ({
  : () => ({
    : .(.),
  }),
})
const { ,  } = 

// This guard verifies a token and adds `user` to the context
const  = (async () => {
  const  = ..?.("Bearer ", "")
  if (!) throw new ("UNAUTHORIZED")

  const  = await verifyToken()
  return {  } // now ctx.user is available downstream
})

When a procedure uses this guard, TypeScript knows that ctx.user exists:

const  = query({
  : [auth],
  : ({  }) => {
    return .user // typed as User — no cast needed
  },
})

Validation-only guards

If you don't need to add anything to the context, just don't return anything. The guard acts as a gatekeeper:

const  = guard(() => {
  if (.user.role !== "admin") {
    throw new KatmanError("FORBIDDEN")
  }
  // no return — ctx is unchanged
})

Guards can be synchronous or asynchronous. When they're sync, Katman skips the async overhead entirely — no Promises are created.

Wraps

A wrap is an "onion-style" middleware. It receives the context and a next() function. You call next() to run the rest of the pipeline, and you can do things both before and after:

const  = k.wrap(async (, ) => {
  const  = .()
  const  = await ()
  .(`Procedure took ${.() - }ms`)
  return  // you must return the result
})

Wraps are useful for:

  • Timing — measure how long a procedure takes
  • Error capture — catch errors and report them (Sentry, Datadog, etc.)
  • Retries — call next() again if it fails
  • Caching — return a cached value instead of calling next()
  • Transactions — start a DB transaction before, commit or rollback after
const  = k.wrap(async (, ) => {
  try {
    return await ()
  } catch () {
    Sentry.captureException()
    throw  // re-throw so the client still gets the error
  }
})

Wraps are always asynchronous because they need to await next(). If you only need to run code before a procedure and don't need the result, use a guard instead — it's faster.

When to use which

NeedUse
Check authenticationGuard
Add data to context (user, session, tenant)Guard
Validate a precondition (throw if invalid)Guard
Measure timingWrap
Catch and report errorsWrap
Transform the result after the procedure runsWrap
Run code both before and afterWrap

The rule of thumb: if you need the result of the procedure, use a wrap. Otherwise, use a guard.

Execution order

When you stack middleware in the use array, guards always run first (in order), then wraps form an onion around the resolver:

const  = k.mutation({
  : [auth, rateLimit, adminOnly, timing, withSentry],
  //    ────── guards ──────────   ────── wraps ──────
  : ({ ,  }) => {
    // your handler
  },
})

Here's what happens when a request comes in:

Request arrives

├─ auth         (guard)   → ctx gets { user }
├─ rateLimit    (guard)   → ctx gets { rateLimit }
├─ adminOnly    (guard)   → throws if not admin

├─ timing       (wrap)    → starts timer
│  ├─ withSentry (wrap)   → try {
│  │  └─ resolve()        → your handler runs
│  │  ← withSentry        → } catch → report to Sentry
│  ← timing               → logs duration

Response sent

Guards run sequentially from left to right. Each guard can see the context additions from previous guards — so rateLimit can access ctx.user that auth added.

Wraps nest from left to right. The leftmost wrap is the outermost layer. In this example, timing wraps around withSentry, which wraps around resolve().

Reusing middleware across procedures

Since guards and wraps are just values, you can share them across your entire codebase:

// src/middleware.ts
export const  = guard(async () => {
  // ...
  return {  }
})

export const  = guard(() => {
  if (.user.role !== "admin") throw new KatmanError("FORBIDDEN")
})

export const  = wrap(async (, ) => {
  const  = .()
  const  = await ()
  .(`${.() - }ms`)
  return 
})

Then use them wherever you need:

import { , ,  } from "./middleware"

const  = query({
  : [, ],
  : ({  }) => .db.users.findMany(),
})

const  = mutation({
  : [, , ],
  : ({ ,  }) => .db.users.delete(.id),
})

Lifecycle hooks

Instead of writing try/catch/finally in every wrap, use lifecycleWrap for a declarative approach:

import {  } from "katman"

const  = ({
  : ({  }) => .("started"),
  : ({ ,  }) => .(`done in ${}ms`),
  : ({  }) => reportToSentry(),
  : ({  }) => metrics.record(),
})

const  = k.query({
  : [],
  : ({  }) => .db.users.findMany(),
})

All four hooks are optional. They're purely for side effects — the procedure result is never modified.

HookWhen it fires
onStartBefore the procedure runs
onSuccessAfter a successful return
onErrorWhen the procedure throws (error is re-thrown after the hook)
onFinishAlways — success or failure (like finally)

This is different from the global lifecycle hooks on the katman instance — lifecycleWrap applies per-procedure via the use array.

Input mapping

Transform the input shape before a procedure runs with mapInput:

import {  } from "katman"

const  = ((: { : string }) => ({
  : .,
}))

const  = k.query({
  : [],
  : ({  }) => db.users.find(.id),
})

Useful for adapting between client and server naming conventions, or pre-processing input in a reusable way.

What's next?

  • Typed Errors — define error maps and use fail() for type-safe error handling
  • Procedures — learn about the short form and full config for defining endpoints
  • Plugins — pre-built guards and wraps for rate limiting, OpenTelemetry, and logging

On this page