Skip to content

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).

-- @name GetUser
-- @returns :one
SELECT id, name, email, created_at FROM users WHERE id = $1;
-- @name ListUsers
-- @returns :many
SELECT id, name FROM users ORDER BY name LIMIT $1;
-- @name CreateUser
-- @returns :exec
INSERT 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()
);

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>.

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})
`;
}

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],
);
}

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 :one
SELECT 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.

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 :many
SELECT u.id, u.name, o.total, o.notes
FROM 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 }
);
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 :many
SELECT id, total, notes, created_at FROM orders
WHERE 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.

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.

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));
}

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.

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];
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