An int8 is read as a bigint, and JSON.stringify refuses a bigint: a
handler that returns a row with one answers 500. Hand the id on as a
string, and say so in the response’s schema, which the OpenAPI document is
built from:
: 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/open"
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/open",
schema: { response: { 200: z.array(Order) } },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing&DeclaredOutgoing<200>;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>Promise<{
id:string;
total: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: async () => {
const
constorders: {
id:bigint;
total:string;
}[]
orders=await
constdb:Kysely<DB>
db
.selectFrom("orders")
.select(["id", "total"])
.where("status", "=", "new")
.execute();
return
constorders: {
id:bigint;
total:string;
}[]
orders.map((
order: {
id: bigint;
total: string;
}
order) => ({ ...
order: {
id: bigint;
total: string;
}
order,
id: string
id: String(
order: {
id: bigint;
total: string;
}
order.
id: bigint
id) }));
},
});
Number(order.id) will do as well, while the ids stay below 2⁵³; past that,
a number rounds them. Types generated with --int8 string read an id as a
string, which needs neither: int8 as a
string.
An id that comes in — in a path, a query or a body — comes in as a string.
A codec checks it, up to the largest int8, and hands the handler a
bigint, while the document says the client sends a string:
: 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",
schema: { params: z.object({ id: Id }) },
handler: (ctx: {
readonlyout:Outgoing;
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:bigint;
};
}) =>Promise<{
cancelled:boolean;
}>
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: async (
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: bigint;
};
}
ctx) => {
const {
constnumUpdatedRows:bigint
The number of rows the update query updated (even if not changed).
The number of rows the update query updated (even if not changed).
numUpdatedRows===0n) {
throwhttpError(404, "ORDER_NOT_FOUND");
}
return {
cancelled: boolean
cancelled: true };
},
});
With @tetsujs/typebox, the codec is TypeBox’s.
A Decode that throws refuses the value with a 422, as a pattern that
does not match does:
import { httpError, route } from"@tetsujs/core";
import { tb, Type } from"@tetsujs/typebox";
const
constId:Type.TCodec<Type.TString, bigint>
Id= Type.Codec(Type.String({
pattern?: string | RegExp |undefined
Specifies a regular expression pattern that the string value must match.
Can be provided as a string (ECMA-262 regex syntax) or a RegExp object.
pattern: "^[1-9][0-9]{0,18}$" }))
.Decode((
text: string
text) => {
const
constid:bigint
id=BigInt(
text: string
text);
if (
constid:bigint
id>9_223_372_036_854_775_807n) {
thrownewError("is larger than the largest int8");
}
return
constid:bigint
id;
})
.Encode((
id: bigint
id:bigint) =>String(
id: bigint
id));
constcancel=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",
schema?: {
readonly params: TypeBoxSchema<Type.TObject<{
id:Type.TCodec<Type.TString, bigint>;
}>>;
} |undefined
Validation schemas; an absent part is neither parsed nor typed.
schema: {
params: TypeBoxSchema<Type.TObject<{
id:Type.TCodec<Type.TString, bigint>;
}>>
params: tb(Type.Object({
id: Type.TCodec<Type.TString, bigint>
id:
constId:Type.TCodec<Type.TString, bigint>
Id })) },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:bigint;
};
}) =>Promise<{
cancelled:boolean;
}>
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: async (
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: bigint;
};
}
ctx) => {
const {
constnumUpdatedRows:bigint
The number of rows the update query updated (even if not changed).
A query of the builder or of a file is typed by the database, and needs no
check of its rows. Raw SQL is typed by what it is told, as in
sql<Report>`…`, and some values depend on the data more than on the
schema: a sum over no rows is NULL, as is a json_agg over none.
Where that matters, check the rows with a schema of your own:
The rows returned by the query. This is always defined and is
empty if the query returned no rows.
rows } =awaitsql`
select count(distinct customer_id)::int4 as customers, sum(total) as revenue
from orders
where status = 'paid'
`.execute(
constdb:Kysely<DB>
db);
const
consttotals: {
customers:number;
revenue:string;
}[]
totals=
constrows:unknown[]
The rows returned by the query. This is always defined and is
empty if the query returned no rows.
rows.map((
row: unknown
row) => Totals.parse(
row: unknown
row));
A row that fails the check is the database disagreeing with the code: a
bug, which the application answers with a 500, not the client’s 422.
Here, a revenue of NULL fails when nothing is paid yet; a
coalesce(sum(total), 0) in the query keeps it a string. A query
file is often the better answer: Postgres
types it, and the generated file says which columns may be NULL.
Kysely’s sql`…` binds every value as a parameter, never as text of the
query. Within it:
sql.ref("orders.status") names a column, and sql.id("orders") a table,
quoted as Postgres reads them;
sql.join(values) writes a list of parameters: sql`id in (${sql.join(ids)})`;
an array for an array type is built in the query, as
sql`array[${sql.join(labels)}]::text[]`: Arrays.
A raw query that binds no value may hold several statements, and resolves
to the rows of the last; with a value, Postgres refuses more than one.
Anything long is clearer as a query file, which Postgres checks when the
types are generated.