ctx.out.status replaces the 200 or 204 of a serialized value. It has
no effect on a Response, which states its own status:
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"
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: {};
}) => {
id: number;
}
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) => {
const
constorder: {
id:number;
}
order=
constorders: {
create(): {
id:number;
};
}
orders.create();
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.
out: Outgoing
Response parameters for the serialized handler result.
out.
status: number |undefined
status=201;
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.
out: Outgoing
Response parameters for the serialized handler result.
out.
headers: Headers
Headers applied to the outgoing response, whatever produced it —
set-cookie values are appended, any other name overwrites.
A live standard Headers, created on first access. It is never
assigned, only mutated — set() to own a header, append() to add to
it — so two hooks writing headers compose instead of overwriting each
other's whole set.
headers.set("location", `/orders/${
constorder: {
id:number;
}
order.
id: number
id}`);
return
constorder: {
id:number;
}
order;
},
});
A ReadableStream or a generator returned bare is a compile error, and a
500 at runtime: as JSON it would be {}. A stream goes out inside a
Response that states its content type. See
Streaming.
ctx.out is what the response carries besides its body. ctx.headers and
ctx.cookies are what arrived; ctx.out.headers and ctx.out.cookies are
what leaves.
Field
status
the status of a serialized result
headers
a standard Headers
cookies
set(name, value, attributes) and delete(name, attributes), see Cookies
Every hook and the handler share the same ctx.out. headers is never
replaced, only changed with set() and append(), so two hooks that write
headers do not overwrite each other’s.
ctx.out.headers is applied to every response that leaves: a serialized
result, a Response the handler built, a hook’s short-circuit, and an
error response. A request id or a rotated session cookie does not vanish
on the 401 it goes with. Headers are merged by name:
set-cookie is appended;
vary is merged, each token once, so a handler’s Vary: Cookie survives
a CORS hook adding Origin;
every other name overwrites the response’s own value.
ctx.out.status, by contrast, applies only to a serialized result. An
error’s status belongs to the error, and a Response carries its own.
schema.response describes what leaves. A single schema checks every
serialized response, whatever its status. A map binds a schema to each
status, and is also the list of statuses the route answers with:
: 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.
Response parameters for the serialized handler result.
out.
status: 201|undefined
status=201;
return
constuser: {
id:number;
name:string;
passwordHash:string;
}
user;
},
}),
remove: route({
method: "DELETE"
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: "DELETE",
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",
schema: { params: UserId,
response: {
readonly 204: null;
}
response: { 204: null } },
handler: (ctx: {
readonlyout:Outgoing&DeclaredOutgoing<204>;
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:number;
};
}) =>void
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<204>;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx) => {
if (!
constusers:Map<number, StoredUser>
users.delete(
ctx: {
readonly out: Outgoing & DeclaredOutgoing<204>;
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: number
id)) throwhttpError(404, "USER_NOT_FOUND");
},
}),
}));
Only a declared status leaves. Setting an undeclared status on
ctx.out.status is a compile error. A response that still leaves with
one, such as the implicit 200 of a handler that forgot to set 201, is
a 500.
null declares a status without a body, such as 204 or 304. A
value returned under it is a 500.
The status picks the schema. The handler may return any of the
declared shapes, and the status it leaves with decides which schema
checks it. The wrong shape for the status compiles, and is a 500.
Entries for statuses the handler never returns, such as a 404 it throws,
only document the error path. A thrown HttpError is answered by the
error mapping, and no response schema is checked
there.
The value the schema returns is what becomes the JSON. In the example
above, the stored user carries a passwordHash that PublicUser does not
name, and Zod’s object schema drops unknown keys, so the hash never leaves.
TypeScript alone would not catch this: a StoredUser satisfies
{ id: number; name: string }, extra field and all.
A response that fails its schema is the server’s fault. It answers 500
with the envelope and nothing of the value, and reportError receives a
ResponseContractError with source: "response". See
Errors.
validateResponses: false on createApp turns every response check off.
The schemas still type the handler and document the route. The framework
never reads NODE_ENV itself, so checking outside production only is your
call:
const
constapp:App<RoutesOf<object[]>>
app=createApp({
routes: object[]
The topology: a group, a controller, or an array of either.
routes,
validateResponses?: boolean |undefined
Whether handler results are checked against schema.response at
runtime. Defaults to true.
The check is a contract and a transformation: the value the validator
returns is what gets serialized, so a schema that strips unknown keys
is what stops a field like passwordHash from leaving the process —
structural typing cannot, because a handler returning
User & { passwordHash } satisfies the declared type. A declared
schema behaves identically in every environment; the framework never
consults NODE_ENV.
false turns the runtime check off entirely. The schema keeps working
for free at the other two levels — it still checks the handler's return
type at compile time and still documents the contract. Validating
outside production only is a decision for the composition root:
An entry can check headers and cookies too, with the keys body, headers
and cookies, and name a body that is not JSON with contentType
(below). Any other key is refused at startup,
so a misspelled part is not silently left unchecked:
: 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"
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 & DeclaredOutgoing<201>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx) => {
const
constorder: {
id:number;
total:number;
}
order=
constorders: {
create(): {
id:number;
total:number;
};
}
orders.create();
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx.
out: Outgoing & DeclaredOutgoing<201>
Response parameters for the serialized handler result.
out.
status: 201|undefined
status=201;
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx.
out: Outgoing & DeclaredOutgoing<201>
Response parameters for the serialized handler result.
out.
headers: Headers
Headers applied to the outgoing response, whatever produced it —
set-cookie values are appended, any other name overwrites.
A live standard Headers, created on first access. It is never
assigned, only mutated — set() to own a header, append() to add to
it — so two hooks writing headers compose instead of overwriting each
other's whole set.
headers.set("location", `/orders/${
constorder: {
id:number;
total:number;
}
order.
id: number
id}`);
return
constorder: {
id:number;
total:number;
}
order;
},
});
headers sees ctx.out.headers after the handler returns, names in
lower case, without set-cookie. Hooks may have added headers too, so
the schema should allow keys it does not name.
cookies sees each cookie the response sets, by name, with the value as
the handler wrote it: opened when signed, "" when deleted.
An entry without body has no body, like null, unless it has a
contentType.
A value the handler returns always leaves as JSON. A CSV export, a file or
an event stream is a Response the handler builds, and contentType on its
entry says what it carries:
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.csv"
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.csv",
schema: { response: { 200: {
contentType: "text/csv"
contentType: "text/csv", body: z.string() } } },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing&DeclaredOutgoing<200>;
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.
handler: () =>
newResponse(toCsv(
constorders: {
all():object[];
}
orders.all()), {
headers?: HeadersInit |undefined
headers: { "content-type": "text/csv" } }),
});
The document describes the status under
text/csv instead of application/json, with body as its schema.
Without body, by the type alone: "application/pdf", "image/*", or
"*/*" for a file of any type.
Returning a value for such a status is a compile error, and so is
returning nothing: a value would leave as JSON, nothing as an empty body.
A type the compiler knows only as a string, in a map declared apart
from the route, counts as not JSON; as const keeps the literal. Where
the compiler does not see the key at all, as in an entry typed as
ResponseEntry, the response is a 500 when responses are validated.
Its headers and cookies are documented, not checked: only a
Response answers such a status, and a Response is not checked.
The type goes alone, "text/csv" and not "text/csv; charset=utf-8":
the charset belongs on the Response. route() throws on anything but a
bare type or a range.
A redirect is a response like any other. Response.redirect works from a
handler or a hook, and cookies set on ctx.out go with it:
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: "/login"
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: "/login",
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.
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.
out: Outgoing
Response parameters for the serialized handler result.
out.
cookies: ResponseCookies
Cookies the response will carry, written by name rather than by
serializing a set-cookie value by hand.
The mirror of
Outgoing.headers
, and it stands to
ctx.cookies exactly as ctx.out.headers stands to ctx.headers:
what arrived on one side, what leaves on the other.
cookies.set("session",
constsessions: {
open():string;
}
sessions.open(), {
httpOnly?: boolean |undefined
httpOnly: true });
return Response.redirect("/orders", 303);
},
});
That redirect is a Response, so it is not checked and not in the
generated document. To have it checked and documented, declare it in the
response map, set the status and location on ctx.out, and return
nothing:
: 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"
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 & DeclaredOutgoing<303>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx) => {
const
constorder: {
id:number;
}
order=
constorders: {
create(): {
id:number;
};
}
orders.create();
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<303>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx.
out: Outgoing & DeclaredOutgoing<303>
Response parameters for the serialized handler result.
out.
status: 303|undefined
status=303;
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<303>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx.
out: Outgoing & DeclaredOutgoing<303>
Response parameters for the serialized handler result.
out.
headers: Headers
Headers applied to the outgoing response, whatever produced it —
set-cookie values are appended, any other name overwrites.
A live standard Headers, created on first access. It is never
assigned, only mutated — set() to own a header, append() to add to
it — so two hooks writing headers compose instead of overwriting each
other's whole set.
A Response the handler builds is sent as it is: no response schema checks
it, the response map does not apply to its status, and the generated
document does not describe it. ctx.out.headers is still applied, and
beforeResponse and afterResponse hooks still see it.
Use it for what JSON is not, such as a file, HTML or a stream. For JSON,
return the value, so the contract stays checked and documented.
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: "/reports/: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: "/reports/:id",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:string;
};
}) =>Promise<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.