Middleware
Guards and wraps — the two kinds of middleware in Silgi, when to use each, and how they execute.
Middleware lets you run code before (and sometimes after) a procedure. Silgi 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:
- Returns an object — its properties get merged into
ctxfor everything that runs after it. - Returns nothing — it acts as a pure check (throw an error to reject the request).
- Throws — the request stops immediately and the client gets an error.
import { , } from 'silgi'
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 = s.$use(auth).$resolve(({ }) => {
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 SilgiError('FORBIDDEN')
}
// no return — ctx is unchanged
})Guards with typed errors
Guards can declare their own error maps. These errors automatically merge into any procedure that uses the guard — in TypeScript, at runtime, and in the OpenAPI spec:
const = guard({
: { : 401 },
: async () => {
const = .headers.authorization?.replace('Bearer ', '')
if (!) throw new SilgiError('UNAUTHORIZED')
return { : await verifyToken() }
},
})
// In the procedure, fail() knows about 'UNAUTHORIZED' from the guard
const = k
.$use()
.$errors({ : 409 })
.$resolve(({ }) => {
// TypeScript allows both 'UNAUTHORIZED' and 'CONFLICT'
('CONFLICT')
})The simple guard(fn) form still works exactly as before. Use guard({ errors, fn }) when you want the guard's errors to be part of the typed API contract. See Typed Errors for more details.
Guards can be synchronous or asynchronous. When they're sync, Silgi 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 = s.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 = s.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
| Need | Use |
|---|---|
| Check authentication | Guard |
| Add data to context (user, session, tenant) | Guard |
| Validate a precondition (throw if invalid) | Guard |
| Measure timing | Wrap |
| Catch and report errors | Wrap |
| Transform the result after the procedure runs | Wrap |
| Run code both before and after | Wrap |
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 via .$use(), guards always run first (in order), then wraps form an onion around the resolver:
const = k
.$use(auth, rateLimit, adminOnly, timing, withSentry)
// ────── guards ────────── ────── wraps ──────
.$resolve(({ , }) => {
// 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 sentGuards 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 SilgiError('FORBIDDEN')
})
export const = wrap(async (, ) => {
const = .()
const = await ()
.(`${.() - }ms`)
return
})Then use them wherever you need:
import { , , } from './middleware'
const = s.$use(, ).$resolve(({ }) => .db.users.findMany())
const = s.$use(, , ).$resolve(({ , }) => .db.users.delete(.id))Lifecycle hooks
Instead of writing try/catch/finally in every wrap, use lifecycleWrap for a declarative approach:
import { } from 'silgi'
const = ({
: ({ }) => .('started'),
: ({ , }) => .(`done in ${}ms`),
: ({ }) => reportToSentry(),
: ({ }) => metrics.record(),
})
const = s.$use().$resolve(({ }) => .db.users.findMany())All four hooks are optional. They're purely for side effects — the procedure result is never modified.
| Hook | When it fires |
|---|---|
onStart | Before the procedure runs |
onSuccess | After a successful return |
onError | When the procedure throws (error is re-thrown after the hook) |
onFinish | Always — success or failure (like finally) |
This is different from the global lifecycle hooks on the silgi instance — lifecycleWrap applies per-procedure via .$use().
Input mapping
Transform the input shape before a procedure runs with mapInput:
import { } from 'silgi'
const = ((: { : string }) => ({
: .,
}))
const = s.$use().$resolve(({ }) => 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 builder for defining endpoints
- Plugins — pre-built guards and wraps for rate limiting, OpenTelemetry, and logging