Lifecycle hooks in fixed slots, controllers that take their dependencies as arguments, and types inferred from the schema to the handler. No decorators, no container, no dependencies.
: 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: "/users/:id"
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.
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 out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx) => {
const
constuser:User|undefined
user=
repo: UserRepository
repo.find(
ctx: {
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx.
params: {
id: number;
}
params.id);
id: number
if (!
constuser:User|undefined
user) throwhttpError(404, "USER_NOT_FOUND");
return
constuser:User
user;
},
}),
}));
Bun.serve({ ...createApp({ routes: users({
repo: UserRepository
repo }) }) });
Caught before it runs
The compiler knows what the framework knows.
constlatestOrder= hook.beforeHandle(
(
ctx: Requires<{
user:User;
}>
ctx:Requires<{
user: User
user:User }>) => ({
order: Order
order:
constorders:OrderService
orders.listFor(
ctx: Requires<{
user:User;
}>
ctx.
user: User
user.
id: number
id)[0] }),
);
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: "/orders/latest"
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/latest",
hooks: { beforeHandle: [latestOrder] },
Error ts(2322) ― Hook requires context 'user' which is not provided by path, schema or preceding hooks
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
order:Order;
}) => unknown
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.
: 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: "/users"
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: "/users",
schema: { response: { 200: UserDto } },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing&DeclaredOutgoing<200>;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => 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 & DeclaredOutgoing<200>;
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 & DeclaredOutgoing<200>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx.
out: Outgoing & DeclaredOutgoing<200>
Response parameters for the serialized handler result.
out.
status: 200|undefined
status=201;
Error ts(2322) ― Type '201' is not assignable to type '200'.
return
constrepo:UserRepository
repo.create({
name: string
name: "Ada",
email: string
email: "ada@example.com" });
},
});
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: "/files/*/raw",
Error ts(2322) ― A '*' must be the entire final segment: Bun's router matches '/files/*', but a wildcard anywhere else never matches any request
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => Response
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.
: 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: "/users"
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.
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 out: Outgoing & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}
ctx) => {
ctx: {
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}
ctx.
out: Outgoing & DeclaredOutgoing<201>
Response parameters for the serialized handler result.
out.
status: 201|undefined
status=201;
return
constrepo:UserRepository
repo.create(
ctx: {
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}
ctx.
body: {
name: string;
email: string;
}
body);
},
});
Hooks sit in fixed slots of the request, not in an onion. What a hook returns joins ctx, typed in everything after it.
constauth= hook.beforeParse(async (ctx) => {
const
constuser:User|undefined
user=await
constsessions:Sessions
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.
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.
method: string
The method read-only property of the POST, etc.) A String indicating the method of the request.
The route this request matched, or nothing when none did.
Optional because a 404, a 405 and a preflight run the pipeline
too, and there is no route behind them — the same reason req.cookies
is optional. In a route's own context the field is not optional: a
hook mounted on a route, and its handler, always have one.
It is the field that makes an observer able to name the endpoint
rather than the URL: ctx.route.path is /users/:id, where
new URL(ctx.req.url).pathname is /users/42. The difference is
cosmetic in a log line and structural in a metric, where a label built
from the second one grows a new series per identifier.
route?.
path: string |undefined
The path as the route declared it, with group prefixes joined and
:params left as they are — /api/users/:id, never /api/users/42.
path, ctx.
res: SentResponse
res.
status: number
The status read-only property of the Response interface contains the HTTP status codes of the response.
: 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: "/orders"
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.
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) =>
constorders:OrderService
orders.listFor(
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.
id: number
id),
});
A controller is a name and a function from its dependencies to its routes. The application is wired by hand, in one place.
: 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: "/orders"
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",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => Order[]
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: () =>
orders: OrderService
orders.listFor(1),
}),
}));
exportconstusersController=controller("Users", (
users: UserRepository
users:UserRepository) => ({
get: 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: "/users/:id"
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: "/users/:id",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:string;
};
}) => User |undefined
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;
};
}
ctx) =>
users: UserRepository
users.find(Number(
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;
};
}
ctx.
params: {
id: string;
}
params.
id: string
id)),
}),
}));
exportdefaultcreateApp({
routes: [ordersController(
constorders:OrderService
orders), usersController(
constrepo:UserRepository
repo)],
});
Your errors, failed validation, 404 and 405 all leave in one envelope. An onError hook on the application can replace it everywhere.
exportconstcancelOrder=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.
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.
The document is built once, at startup, from the routes you already declared: their schemas, their responses and the guards you annotated.
import { docs } from"@tetsujs/openapi";
exportdefaultcreateApp({
routes: [
usersController(
constrepo:UserRepository
repo),
docs({
info: DocumentInfo
Title, version and the rest of the document's info block.
info: {
title: string
title: "Users API",
version: string
version: "1.0.0" } }),
],
});
// GET /openapi.json — the document
// GET /docs — the page
A frontend calls the API with the types its routes declare, from a contract generated from the application: no server code, no Bun.
import { createClient } from"@tetsujs/client";
importtype { Api } from"./api.contract"; // generated
const
constapi:Client<Api, ClientTypes>
api=createClient<Api>({
baseUrl?: string |URL|undefined
Where the application is, put before every path —
https://api.example.com or https://example.com/api. A trailing
slash is dropped. Empty by default: the paths stay relative, for a
fetch that resolves them itself.
baseUrl: "https://api.example.com" });
constres=await
constapi:Client<Api, ClientTypes>
api.get("/users/:id", {
params: {
id: number;
}
params: {
id: number
id: 1 } });
if (res.
ok: boolean
ok) {
console.log(
constres:Success<200, {
id:number;
name:string;
}>
res.
data: {
id: number;
name: string;
}
The body, parsed when it is JSON; undefined otherwise.
Migrations are SQL, and the types come from the database they build. A query is Kysely's, or a file of SQL that Postgres types; the controller takes the database as it takes any dependency.
: 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: "/customers/:id/orders"
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.
A postgres:// URL of the database: the server, the user and the
database it names are the ones used. The database is given apart from
the URL as well, since Bun 1.4.4 would take PGDATABASE over it.
Processor time per request, lower is better. The same four routes in every framework, each server and load generator in its own process. Not the fastest on every route, but close to Bun on all of them.
GET /ping
Bun, no framework4.78 µs
Tetsu4.95 µs +0.17
Elysia 1.44.89 µs +0.11
Hono 4.135.16 µs +0.38
GET /users/:id + 2 hooks
Bun, no framework4.81 µs
Tetsu5.09 µs +0.28
Elysia 1.45.13 µs +0.32
Hono 4.135.87 µs +1.06
POST /items, validated
Bun, no framework5.85 µs
Tetsu6.60 µs +0.75
Elysia 1.46.31 µs +0.46
Hono 4.136.88 µs +1.03
404
Bun, no framework4.82 µs
Tetsu5.13 µs +0.31
Elysia 1.44.91 µs +0.09
Hono 4.135.41 µs +0.59
Bun 1.4.2, Apple M5 Pro, 2026-09-27, medians of three interleaved rounds. Method and caveats
Principles
Iron, not magic.
Controllers without decorators
A named function from its dependencies to its routes, called once where the application is wired.
Flat, and no magic
No registry, no file scanning, no reflection, no container. Any point of the application reads top to bottom.
Types do not lie
A field is on ctx exactly when it exists: the body after validation, the user after the hook that found them.
Explicit over DRY
A route lists its hooks by slot, in the order they run. Similar routes repeat them instead of inheriting them.
The platform, not a wrapper
Bun's router, Headers, CookieMap, the real server. No dependencies in the core.