TypeScript
Backends: typescript-postgres (postgres.js), typescript-pg (node-postgres), typescript-kysely (Kysely) | Engine: PostgreSQL (typescript-kysely also targets MySQL, SQLite, MSSQL, MariaDB, and Redshift – see Kysely below)
All three backends share the same type mappings and TypeScript interfaces. They differ in query execution. Two further TypeScript backends, typescript-node-sqlite and typescript-wasm-sqlite, target SQLite only and generate synchronous code – see typescript-node-sqlite and typescript-wasm-sqlite below.
Four of the eleven TypeScript backends are also reachable under a javascript-* name that emits plain JSDoc-typed .js instead of .ts – see JavaScript output (JSDoc).
SQL input
Section titled “SQL input”-- @name GetUser-- @returns :oneSELECT id, name, email, created_at FROM users WHERE id = $1;
-- @name ListUsers-- @returns :manySELECT id, name FROM users ORDER BY name LIMIT $1;
-- @name CreateUser-- @returns :execINSERT INTO users (name, email) VALUES ($1, $2);Schema:
CREATE TABLE users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());Generated code – shared interfaces
Section titled “Generated code – shared interfaces”Every generated file starts with a provenance header, then its driver import
(integration_tests/typescript-pg/generated/queries.ts:1-2):
// scythe:provenance v=0.15.0 backend=typescript-pg engine=postgresql schema=sch1:... queries=q1:...import type { PoolClient } from "pg";export interface GetUserRow { id: number; name: string; email: string | null; created_at: Date;}
export interface ListUsersRow { id: number; name: string;}Note: generated field names mirror the SQL column names in snake_case by default – set
field_case = "camelCase" to rename them. Function names (getUser, listUsers) are
camelCase, per fn_case.
Every :one query on every TypeScript backend returns Promise<Row | null> and ends
return rows[0] ?? null; – none return a bare Promise<Row>.
postgres.js
Section titled “postgres.js”Uses tagged template literals for query parameterization. The handle type is a bare Sql, imported
with import type { Sql } from "postgres" – not postgres.Sql
(integration_tests/typescript-postgres/generated/queries.ts:1-2).
import type { Sql } from "postgres";
export async function getUser( sql: Sql, id: number,): Promise<GetUserRow | null> { const rows = await sql<GetUserRow[]>` SELECT id, name, email, created_at FROM users WHERE id = ${id} `; return rows[0] ?? null;}export async function listUsers( sql: Sql, limit: number,): Promise<ListUsersRow[]> { const rows = await sql<ListUsersRow[]>` SELECT id, name FROM users ORDER BY name LIMIT ${limit} `; return rows;}export async function createUser( sql: Sql, name: string, email: string | null,): Promise<void> { await sql` INSERT INTO users (name, email) VALUES (${name}, ${email}) `;}pg (node-postgres)
Section titled “pg (node-postgres)”Uses $N positional parameters with client.query(). The handle type is PoolClient (via
import type { PoolClient } from "pg"), not Client.
import type { PoolClient } from "pg";
export async function getUser( client: PoolClient, id: number,): Promise<GetUserRow | null> { const { rows } = await client.query<GetUserRow>( "SELECT id, name, email, created_at FROM users WHERE id = $1", [id], ); return rows[0] ?? null;}export async function listUsers( client: PoolClient, limit: number,): Promise<ListUsersRow[]> { const { rows } = await client.query<ListUsersRow>( "SELECT id, name FROM users ORDER BY name LIMIT $1", [limit], ); return rows;}export async function createUser( client: PoolClient, name: string, email: string | null,): Promise<void> { await client.query( "INSERT INTO users (name, email) VALUES ($1, $2)", [name, email], );}Kysely
Section titled “Kysely”typescript-kysely is dialect-parameterised, not driver-parameterised: generated functions take a QueryExecutorProvider handle (not a concrete Kysely<DB>) and execute through Kysely’s sql tagged-template. Kysely’s own query compiler renders whatever placeholder syntax the connected Dialect needs at runtime, so the same generated call site works against every Kysely dialect scythe pins and tests – PostgreSQL, MySQL, SQLite, MSSQL, MariaDB, plus Redshift via the PostgreSQL dialect – and, being wire-compatible, against third-party dialects scythe does not pin or test, such as libsql, PlanetScale, Cloudflare D1, Neon, PGlite, or a community node:sqlite/wasm-sqlite Kysely adapter.
For synchronous SQLite access without Kysely or a Promise-based driver at all, see the dedicated typescript-node-sqlite and typescript-wasm-sqlite backends below.
-- @name GetUser-- @returns :oneSELECT id, name, email, created_at FROM users WHERE id = ?;(SQLite/MySQL source SQL uses bare ?; PostgreSQL uses $1; MSSQL uses @p1 – the engine set in scythe.toml picks which syntax scythe expects, but none of it survives into the generated code, since every placeholder becomes a ${} interpolation regardless.)
import { type QueryExecutorProvider, sql } from "kysely";
export async function getUser( db: QueryExecutorProvider, id: number,): Promise<GetUserRow | null> { const result = await sql<GetUserRow>`SELECT id, name, email, created_at FROM users WHERE id = ${id}`.execute(db); return result.rows[0] ?? null;}The parameter is QueryExecutorProvider, not Kysely<DB> – there is no <DB> generic on the
generated function at all. Any Kysely instance satisfies QueryExecutorProvider structurally, so
passing a concrete Kysely<YourSchema> still works
(integration_tests/typescript-kysely/generated/queries.ts:1-2,23-31).
Pass any Kysely instance – built-in or third-party dialect – since the generated code never hardcodes a placeholder format:
import { Kysely, SqliteDialect } from "kysely";import Database from "better-sqlite3";// or a third-party dialect, e.g. from kysely-sqlite-tools / wasm-sqlite / node:sqlite
const db = new Kysely<any>({ dialect: new SqliteDialect({ database: new Database("app.db") }),});
const user = await getUser(db, 1);:batch queries run through Kysely’s dialect-agnostic db.transaction().execute(...) API instead of hand-rolled BEGIN/COMMIT/ROLLBACK SQL text, so batches also work unmodified across every dialect.
Outer-join precision (outer_join_unions)
Section titled “Outer-join precision (outer_join_unions)”A hand-written Kysely query has no way to express that a LEFT JOIN’s columns are null together – Kysely infers result types from the query shape, not your schema’s NOT NULL constraints, so every joined column just becomes independently optional. typescript-kysely supports the same opt-in outer_join_unions option as the other TypeScript backends: when a joined relation projects at least one NOT NULL column, scythe emits a discriminated union instead, ruling out states the query can never produce.
-- @name GetUserOrders-- @returns :manySELECT u.id, u.name, o.total, o.notesFROM users u LEFT JOIN orders o ON u.id = o.user_id;With outer_join_unions = true (and orders.total NOT NULL, orders.notes nullable):
export type GetUserOrdersRow = { id: number; name: string;} & ( | { total: string; notes: string | null } | { total: null; notes: null });Options
Section titled “Options”| Option | Values | Default | Effect |
|---|---|---|---|
row_type |
interface, zod |
interface |
Emit plain TypeScript interfaces or Zod schemas + inferred types |
outer_join_unions |
true, false |
false |
Discriminated unions for outer-join nullability instead of independent optionals |
structs_only |
true, false |
false |
Emit only row types (interfaces/Zod schemas, enums, composites) – no query functions, no driver import |
field_case |
snake_case, camelCase |
snake_case |
Case convention for generated row/interface field names and function parameter names |
structs_only is supported by every TypeScript backend, including typescript-postgres, typescript-pg, and typescript-kysely. Combined with row_type = "zod" it produces a types-only package with no driver dependency:
[[sql.gen]]backend = "typescript-pg"output = "src/generated/types"row_type = "zod"structs_only = "true"Every key besides row_type, outer_join_unions, structs_only, and field_case is rejected –
an unrecognized option aborts generation with a “did you mean” suggestion rather than being
silently ignored. See field_case in the Configuration
guide for the runtime-remap behavior it triggers and its collision-detection error.
[[sql.gen]]backend = "typescript-pg"output = "src/generated"field_case = "camelCase"export interface GetUserRow { id: number; userName: string;}typescript-node-sqlite and typescript-wasm-sqlite
Section titled “typescript-node-sqlite and typescript-wasm-sqlite”Two TypeScript backends target SQLite only (engine = "sqlite") and generate synchronous code – no async, no Promise – unlike every other TypeScript backend on this page:
| Backend | Driver | Import |
|---|---|---|
typescript-node-sqlite |
Node’s built-in node:sqlite module (DatabaseSync), zero npm dependencies |
import type { DatabaseSync } from "node:sqlite"; |
typescript-wasm-sqlite |
@sqlite.org/sqlite-wasm, synchronous OO1 API |
import type { Database } from "@sqlite.org/sqlite-wasm"; |
node:sqlite requires --experimental-sqlite on Node 22 and is unflagged from Node 23.4 onward – generated code needs Node 23.4+ to run without the flag.
Given:
-- @name GetOrdersByUser-- @returns :manySELECT id, total, notes, created_at FROM ordersWHERE user_id = ? ORDER BY created_at DESC;typescript-node-sqlite generates:
export function getOrdersByUser( db: DatabaseSync, user_id: number,): GetOrdersByUserRow[] { const stmt = db.prepare(`SELECT id, total, notes, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC`); return stmt.all(user_id) as unknown as GetOrdersByUserRow[];}typescript-wasm-sqlite generates the equivalent using db.selectObjects(...) instead of db.prepare(...).all(...). Neither backend’s DatabaseSync/Database handle has a .transaction() helper, so :batch queries wrap explicit BEGIN/COMMIT/ROLLBACK statements instead.
Both backends support the same row_type, outer_join_unions, structs_only, and field_case
options as the other TypeScript backends.
Other TypeScript backends
Section titled “Other TypeScript backends”Six further shipped TypeScript backends are not covered on this page, each targeting one engine:
typescript-mysql2 (MySQL/MariaDB), typescript-better-sqlite3 (SQLite), typescript-duckdb
(DuckDB), typescript-mssql (MSSQL, mssql/tedious), typescript-oracledb (Oracle,
node-oracledb), and typescript-snowflake (Snowflake). See Backend Architecture
for the full backend list.
JavaScript output (JSDoc)
Section titled “JavaScript output (JSDoc)”Four registry names emit plain JavaScript instead of TypeScript. They are an emit mode on the
TypeScript backend structs above – not separate backends, and not separate manifests – selected by
the name you write in backend:
| Registry name | TypeScript counterpart | Handle type | Engines |
|---|---|---|---|
javascript-postgres |
typescript-postgres |
import("postgres").Sql |
PostgreSQL, CockroachDB, Redshift |
javascript-pg |
typescript-pg |
import("pg").PoolClient |
PostgreSQL, CockroachDB, Redshift |
javascript-mysql2 |
typescript-mysql2 |
import("mysql2/promise").Pool |
MySQL, MariaDB |
javascript-better-sqlite3 |
typescript-better-sqlite3 |
import("better-sqlite3").Database |
SQLite |
The other seven TypeScript backends have no JavaScript counterpart. There are no aliases for these four names.
[[sql.gen]]backend = "javascript-pg"output = "src/generated"Output is queries.js (not queries.ts) – ESM, no build step, every type carried in JSDoc
comments. No driver import is emitted at all: driver types are referenced inline as
import("pg").PoolClient inside the @param tag, so the file has no runtime or type-only import
of pg. The provenance header names the JavaScript backend:
// scythe:provenance v=0.15.0 backend=javascript-pg engine=postgresql schema=sch1:... queries=q1:...For the SQL input at the top of this page, javascript-pg generates:
/** * Row type for GetUser queries. * @typedef {object} GetUserRow * @property {number} id * @property {string} name * @property {string | null} email * @property {Date} created_at */
/** * Fetch a single GetUserRow or null. * @param {import("pg").PoolClient} client * @param {number} id * @returns {Promise<GetUserRow | null>} */export async function getUser(client, id) { const { rows } = await client.query( `SELECT id, name, email, created_at FROM users WHERE id = $1`, [id], ); return rows[0] ?? null;}Row types are @typedef {object} blocks with one @property line per column. A nullable column is
always rendered {T | null} – never JSDoc’s optional-property forms @property {T} [name] or
name?. Those mean the property may be absent; a nullable SQL column is always present and may
hold null. :grouped queries emit paired child and parent typedefs, the parent carrying
@property {ChildRow[]} children.
Signatures carry no type annotations, and TypeScript-only expression syntax is avoided throughout:
where the TypeScript path writes expr as T, JSDoc mode writes /** @type {T} */ (expr).
javascript-better-sqlite3 is synchronous, mirroring its TypeScript counterpart:
/** * Fetch all ListUsersRow rows. * @param {import("better-sqlite3").Database} db * @param {number} limit_val * @returns {ListUsersRow[]} */export function listUsers(db, limit_val) { const stmt = db.prepare(`SELECT id, name FROM users ORDER BY name LIMIT ?`); return /** @type {ListUsersRow[]} */ (stmt.all(limit_val));}Unsupported options
Section titled “Unsupported options”Three of the TypeScript option settings need syntax a plain .js file cannot carry, so
they are hard errors here rather than silent downgrades. Each error names the TypeScript backend to
use instead:
| Option | JSDoc mode | Why |
|---|---|---|
row_type = "zod" |
Error | export type X = z.infer<...> is a TypeScript type alias |
outer_join_unions |
Error | The discriminated union is a type X = A & (B | C) alias |
field_case = "camelCase" |
Error | The field remap needs an as T assertion |
field_case = "snake_case" |
Supported | The default – driver rows pass straight through |
structs_only |
Supported | Emits only typedefs; there was no driver import to drop |
Enums always take the const object plus derived-type form, spelled /** @type {const} */ and
/** @typedef {typeof UserStatusValues[keyof typeof UserStatusValues]} UserStatus */ –
including under javascript-postgres, whose TypeScript counterpart emits a real enum (see
Enum generation).
Generated output is validated in CI against the real toolchain: node --check for ESM parsing, and
tsc --checkJs --strict --allowJs --noEmit for the JSDoc types.
Enum generation
Section titled “Enum generation”CREATE TYPE user_status AS ENUM ('active', 'inactive', 'banned');Only typescript-postgres emits a real TypeScript enum. typescript-pg and typescript-kysely
emit a const object of values plus a derived type instead – enum values are not narrowable the
way a driver returns raw strings, and the as const pattern round-trips through pg/Kysely without a
runtime enum object.
typescript-postgres:
export enum UserStatus { Active = "active", Inactive = "inactive", Banned = "banned",}typescript-pg and typescript-kysely:
export const UserStatusValues = { Active: "active", Inactive: "inactive", Banned: "banned",} as const;
export type UserStatus = typeof UserStatusValues[keyof typeof UserStatusValues];Type mappings
Section titled “Type mappings”| SQL Type | Neutral | TypeScript |
|---|---|---|
SERIAL / INTEGER |
int32 |
number |
BIGINT |
int64 |
number |
TEXT / VARCHAR |
string |
string |
BOOLEAN |
bool |
boolean |
BYTEA |
bytes |
Buffer |
UUID |
uuid |
string |
NUMERIC |
decimal |
string |
DATE / TIME |
date / time |
string |
TIMESTAMPTZ |
datetime_tz |
Date |
INTERVAL |
interval |
string |
JSON / JSONB |
json |
Record<string, unknown> |
TEXT[] |
array<string> |
string[] |
| nullable column | nullable |
T | null |