Skip to content

Hook slots

A hook is a function bound to one slot of the request lifecycle by a hook.<slot>() factory. This page lists the slots, the factories and the checks. Lifecycle hooks explains the model.

beforeParse → parse → beforeValidation → validate → beforeHandle
→ handler → beforeResponse → afterResponse (onError on failure)
Slot Runs ctx has, on a route A return value A throw
beforeParse first, before the body is read req, server, out, route, startedAt, params as strings an object joins ctx; a Response ends the request; nothing goes on goes to onError
beforeValidation after the body is parsed, before any schema runs the above, body as parsed, rawBody when the route asks as beforeParse goes to onError
beforeHandle after validation, right before the handler the above, with params, query, headers, cookies and body validated as beforeParse goes to onError
beforeResponse once the response exists: after the handler, a short-circuit or an error res: Response; validated parts and hook fields optional a Response replaces ctx.res; anything else is ignored goes to onError; the remaining hooks run on the error response
afterResponse as the response goes to Bun, on every outcome res: SentResponse, without the body; validated parts and hook fields optional ignored; a promise is not awaited reported, source: "afterResponse"
onError when a stage throws error: unknown; validated parts and hook fields optional a Response answers; nothing passes the error on reported, source: "onError"; the next hook tries

parse reads the body when the route declares schema.body, bodyType or rawBody, and fails with 400 or 413. validate checks every declared part at once and fails with 422.

  • Order. Slots run in lifecycle order, whatever order they are written in. Inside a slot, the array order is the run order. Application hooks run first, then each group’s from the outermost in, then the route’s. onError runs the other way, from the route outwards.
  • Short-circuit. A Response returned before the handler skips the remaining stages and the handler. It still goes through beforeResponse, ctx.out.headers and afterResponse. Build a new one on every call: a body can be read only once.
  • beforeResponse sees every response, error responses included. Each hook runs at most once per request: if one throws, the error response continues from the next hook.
  • afterResponse hooks start in order without waiting for each other. Their synchronous part adds to the response’s latency. Read what you need from ctx.req and ctx.res before the first await.
  • 404, 405 and OPTIONS run the application’s hooks only.
  • A successful WebSocket handshake runs no response hooks: there is no response.

SlotBases[slot] is what an unannotated hook’s ctx is typed as. It is also the most a group or application hook may require, besides what the hooks before it at the same level contribute.

Slot SlotBases[slot]
beforeParse BaseCtx & { params: Record<string, string> }
beforeValidation BaseCtx & { params: Record<string, string>; body: unknown }
beforeHandle BaseCtx
beforeResponse BaseCtx & { res: Response }
afterResponse BaseCtx & { res: SentResponse }
onError BaseCtx & { error: unknown }

BaseCtx is described in Context fields.

hook has one factory per slot: hook.beforeParse, hook.beforeValidation, hook.beforeHandle, hook.beforeResponse, hook.afterResponse and hook.onError. Each takes a function of ctx and returns a hook typed with what it requires (its ctx parameter, or the slot’s base when unannotated) and what it contributes (its return type). Only a hook made by a factory can be mounted.

const auth = hook.beforeParse(async (ctx) => {
const
const user: User | undefined
user
= await
const sessions: {
verify(header: string | null): Promise<User | undefined>;
}
sessions
.verify(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.get("authorization"));
if (!
const user: User | undefined
user
) throw new HttpError(401);
return {
user: User
user
};
});
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/me"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/me",
hooks: { beforeParse: [auth] },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}) => User

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}
ctx
) =>
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}
ctx
.user,
user: User
});

What a returned object contributes:

  • its own enumerable fields, typed in every later hook and in the handler. Return a plain object literal: a class instance’s methods and getters live on its prototype and are not copied;
  • optional fields when the hook may also return nothing (return user ? { user } : undefined);
  • never req, server, out, route, res, error, startedAt or rawBody: the pipeline owns them and drops them from a returned object.

A Response contributes nothing; it short-circuits instead. An onError hook returns a Response or nothing; returning an object is a compile error.

A returned cookies object is checked like the request’s cookies: under a signed name, a value whose signature does not hold is dropped.

type Requires<T extends object> = BaseCtx & T;

A reusable hook declares what it needs instead of where it sits. Mounting it where nothing provides that is a compile error naming the missing field:

const withOrder = hook.beforeHandle(
async (
ctx: Requires<{
params: {
id: string;
};
user: User;
}>
ctx
: Requires<{
params: {
id: string;
}
params
: {
id: string
id
: string };
user: User
user
: User }>) => {
const
const order: Order | undefined
order
= await
const orders: {
find(id: string): Promise<Order | undefined>;
}
orders
.find(
ctx: Requires<{
params: {
id: string;
};
user: User;
}>
ctx
.
params: {
id: string;
}
params
.
id: string
id
);
if (!
const order: Order | undefined
order
||
const order: Order
order
.
ownerId: number
ownerId
!==
ctx: Requires<{
params: {
id: string;
};
user: User;
}>
ctx
.
user: User
user
.
id: number
id
) throw new HttpError(404);
return {
order: Order
order
};
},
);
route({
method: "POST"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "POST",
path: "/orders/:id/cancel"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/orders/:id/cancel",
hooks: { beforeParse: [auth], beforeHandle: [withOrder] },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
user: User;
order: Order;
}) => {
cancelled: string;
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
user: User;
order: Order;
}
ctx
) => ({
cancelled: string
cancelled
:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
user: User;
order: Order;
}
ctx
.
order: Order
order
.
id: string
id
}),
});

At compile time, where the hooks are mounted:

Error When
HookSlotError a hook sits in a slot other than its own, its type was widened to AnyHook, or a function is not wrapped by a hook.* factory
HookRequirementError a hook requires a field nothing before it provides; the message names it
HookStackError a slot holds a widened array instead of a tuple, such as const shared = [auth] without as const
HooksIndexError hooks is typed with an index signature, so its slots cannot be checked

What createApp refuses at startup is listed in createApp.

Type
Hook<Slot, Req, Ext> a hook: its slot, what it requires, what it contributes
AnyHook any hook
SlotName "beforeParse" | "beforeValidation" | "beforeHandle" | "beforeResponse" | "afterResponse" | "onError"
SlotBases what each slot guarantees on its own
SentResponse Response without its body
HooksConfig, GroupHooks hooks keyed by slot
MergedHooks the chains of a route table entry, every slot present
HandlerCtx, ResponseCtx, ErrorCtx the context of a route’s handler, response-slot hook, onError hook