Skip to content

Recipes

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:

import { route } from "@tetsujs/core";
import { z } from "zod";
const Order = z.object({ id: z.string(), total: z.string() });
const open = 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/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: {
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: {};
}) => 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
const orders: {
id: bigint;
total: string;
}[]
orders
= await
const db: Kysely<DB>
db
.selectFrom("orders")
.select(["id", "total"])
.where("status", "=", "new")
.execute();
return
const orders: {
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:

import { httpError, route } from "@tetsujs/core";
import { z } from "zod";
const Id = z.codec(
z.string().regex(/^[1-9]\d{0,18}$/),
z.bigint().max(9_223_372_036_854_775_807n),
{
decode: (value: string, payload: z.core.ParsePayload<string>) => z.core.util.MaybeAsync<bigint>
decode
: (
text: string
text
) => BigInt(
text: string
text
),
encode: (value: bigint, payload: z.core.ParsePayload<bigint>) => z.core.util.MaybeAsync<string>
encode
: (
id: bigint
id
) => String(
id: bigint
id
) },
);
const cancel = 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: { params: z.object({ id: Id }) },
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: 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 {
const numUpdatedRows: bigint

The number of rows the update query updated (even if not changed).

numUpdatedRows
} = await
const db: Kysely<DB>
db
.updateTable("orders")
.set({
note?: ValueExpression<DB, "orders", string | null> | undefined
note
: "cancelled" })
.where("id", "=",
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
.
params: {
id: bigint;
}
params
.id)
id: bigint
.executeTakeFirst();
if (
const numUpdatedRows: bigint

The number of rows the update query updated (even if not changed).

numUpdatedRows
=== 0n) {
throw httpError(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
const Id: 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
const id: bigint
id
= BigInt(
text: string
text
);
if (
const id: bigint
id
> 9_223_372_036_854_775_807n) {
throw new Error("is larger than the largest int8");
}
return
const id: bigint
id
;
})
.Encode((
id: bigint
id
: bigint) => String(
id: bigint
id
));
const cancel = 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
:
const Id: Type.TCodec<Type.TString, bigint>
Id
})) },
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: 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 {
const numUpdatedRows: bigint

The number of rows the update query updated (even if not changed).

numUpdatedRows
} = await
const db: Kysely<DB>
db
.updateTable("orders")
.set({
note?: ValueExpression<DB, "orders", string | null> | undefined
note
: "cancelled" })
.where("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: {
id: bigint;
};
}
ctx
.
params: {
id: bigint;
}
params
.id)
id: bigint
.executeTakeFirst();
if (
const numUpdatedRows: bigint

The number of rows the update query updated (even if not changed).

numUpdatedRows
=== 0n) {
throw httpError(404, "ORDER_NOT_FOUND");
}
return {
cancelled: boolean
cancelled
: true };
},
});

Either way, the OpenAPI document says the client sends a string, with the pattern, and the handler is handed a bigint.

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:

import { sql } from "kysely";
import { z } from "zod";
const Totals = z.object({ customers: z.number(), revenue: z.string() });
const {
const rows: unknown[]

The rows returned by the query. This is always defined and is empty if the query returned no rows.

rows
} = await sql`
select count(distinct customer_id)::int4 as customers, sum(total) as revenue
from orders
where status = 'paid'
`.execute(
const db: Kysely<DB>
db
);
const
const totals: {
customers: number;
revenue: string;
}[]
totals
=
const rows: 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.

Bun.SQL has no cursor, so a long result is read in pages, each after the last key of the one before:

let
let after: bigint
after
= 0n;
for (;;) {
const
const page: {
id: bigint;
total: string;
}[]
page
= await
const db: Kysely<DB>
db
.selectFrom("orders")
.select(["id", "total"])
.where("id", ">",
let after: bigint
after
)
.orderBy("id")
.limit(1_000)
.execute();
for (const
const row: {
id: bigint;
total: string;
}
row
of
const page: {
id: bigint;
total: string;
}[]
page
) {
write(
const row: {
id: bigint;
total: string;
}
row
);
}
const
const last: {
id: bigint;
total: string;
} | undefined
last
=
const page: {
id: bigint;
total: string;
}[]
page
.at(-1);
if (
const last: {
id: bigint;
total: string;
} | undefined
last
=== undefined) {
break;
}
let after: bigint
after
=
const last: {
id: bigint;
total: string;
}
last
.
id: bigint
id
;
}

An index on the key keeps every page as quick as the first, which an offset does not.