Skip to content

Context and its types

ctx is the object a request carries through its hooks and its handler. A field is on it only once it exists at that point of the request: the body after it was read, ctx.user after the hook that returned it. Its type is inferred at the route() call from the path, the schemas and the hooks, so you never annotate it in a handler.

  • Always: req and server, Bun’s own request and server, not wrappers; out, what the response will carry; startedAt, a performance.now() reading taken as the request arrived.
  • Where a route matched: route, the matched route’s method, path, controller and name; params, the path parameters.
  • With a schema: query, headers, cookies and body, validated, from beforeHandle. A body the route reads is there as parsed from beforeValidation, and so is rawBody with rawBody: true.
  • In their slots: res in beforeResponse and afterResponse, error in onError.
  • After a hook: whatever that hook returned.

ctx.server.requestIP(ctx.req) gives the client’s address; behind a proxy it is the proxy’s, see Behind a proxy.

Write to ctx.out, do not replace it: ctx.out.status = 201, ctx.out.headers.set(…), ctx.out.cookies.set(…). Its headers are added to every response, error responses included. See Responses and Cookies.

The context reference lists every field with its type and the slot it exists from.

ctx.query, ctx.headers, ctx.cookies and ctx.body exist only on a route that declares them. Without a query schema there is no ctx.query at all, not even an unknown one:

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: "/notes"

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
: "/notes",
handler: any

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: {};
}
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: {};
}
ctx
.query.page,
Error ts(2339) ― Property 'query' does not exist on type '{ readonly req: Request & { readonly cookies?: CookieMap | undefined; }; readonly server: Server<unknown>; readonly out: Outgoing; readonly route: RouteInfo; readonly startedAt: number; readonly params: {}; }'.
});

Unvalidated input has no type worth trusting, and an unknown field is one cast away from being used as if it were checked. A part without a schema is also not read at all. Declare a schema for the part you need, and the field arrives typed from its output. For raw access, ctx.req is always there.

ctx.params always exists, typed from the path.

A hook in beforeParse, beforeValidation or beforeHandle adds fields by returning an object. Those fields are typed in the hooks after it and in the handler:

import { hook, HttpError, route } from "@tetsujs/core";
const session = hook.beforeParse((ctx) => {
const
const found: Session | undefined
found
=
const sessions: {
get(token: string): Session | undefined;
}
sessions
.get(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 found: Session | undefined
found
) throw new HttpError(401);
return {
session: Session
session
:
const found: Session
found
};
});
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: [session] },
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: {};
session: Session;
}) => {
id: 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: {};
session: Session;
}
ctx
) => ({
id: string
id
:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
session: Session;
}
ctx
.session.
userId: string
userId
}),
session: Session
});

A later hook’s field replaces an earlier one’s of the same name. A hook before validation may change ctx.query or ctx.body, but validation runs after it, and the handler sees the schema’s output.

In beforeResponse, afterResponse and onError these fields are optional. Those slots run on every outcome, including a request refused before the hook that adds the field ran.

A hook reused across routes cannot know which route it will be mounted on. It states what it needs instead, with Requires<{ … }> on its parameter:

import type { Requires } from "@tetsujs/core";
import { hook, HttpError } from "@tetsujs/core";
export const withNote = hook.beforeHandle(
async (
ctx: Requires<{
params: {
id: number;
};
user: User;
}>
ctx
: Requires<{
params: {
id: number;
}
params
: {
id: number
id
: number };
user: User
user
: User }>) => {
const
const note: Note | undefined
note
= await
const notes: {
find(id: number): Promise<Note | undefined>;
}
notes
.find(
ctx: Requires<{
params: {
id: number;
};
user: User;
}>
ctx
.
params: {
id: number;
}
params
.
id: number
id
);
if (!
const note: Note | undefined
note
||
const note: Note
note
.
owner: string
owner
!==
ctx: Requires<{
params: {
id: number;
};
user: User;
}>
ctx
.
user: User
user
.
id: string
id
) throw new HttpError(404);
return {
note: Note
note
};
},
);

Requires<T> is the base context (req, server, out, startedAt) plus T. Where the hook is mounted, the compiler checks that the path, the schemas and the earlier hooks provide those fields. Mounted on a route without a params schema, where params.id is a string, or with no hook providing user, it is a compile error that names the missing field.

Why a group hook’s field is not in the handler’s type

Section titled “Why a group hook’s field is not in the handler’s type”

A hook mounted on a group runs for every route under it, but the fields it returns are not in the handlers’ types. A controller is typed where it is written, not where it is mounted, and the same controller could be mounted under an authenticated group and outside one.

The field is still there at runtime, but to use it in a handler, mount the hook on the route itself, on each route that reads the field:

hooks: { beforeParse: [auth] },

To reuse a whole set of hooks across routes, see Mounting hooks.

Use group hooks for work that needs no typed field later: guards that refuse, logs, metrics, CORS. See Groups and mounting for what group hooks can see of each other.

A hook cannot replace the framework’s own fields: req, server, out, route, res, error, startedAt and rawBody. If a hook returns one of them, the key is dropped at runtime and left out of the hook’s type. Otherwise a hook could, for example, forge server and defeat every address-based rate limit behind it. The keys __proto__, constructor and prototype are dropped too.

params, query, body, headers and cookies are not protected; hooks may normalize them.

Only the returned object’s own string keys are copied. Return a plain object literal: the methods and getters of a class instance are not copied.