Appearance
Adapters
@typesql/core is type-only: sql("SELECT …", …params) returns { text, values } plus phantom Row/Params types. An adapter adds two things for one driver or ORM:
- a type map describing how that driver's default parsers turn each Postgres type into a JS value;
- a
createSql<DB>(client)that uses that map and runs the queries onclient, withsql.transaction(fn)for transactions.
| Package | Runs queries with | Schema from | Default type map |
|---|---|---|---|
@typesql/pg | node-postgres 8.x: Pool, Client, PoolClient | your DB interface | PgTypeMap |
@typesql/postgres | postgres.js 3.x: Sql, TransactionSql, ReservedSql | your DB interface | PostgresJsTypeMap, PostgresJsBigIntTypeMap |
@typesql/kysely | a Kysely or Transaction | your Kysely DB interface (kysely-codegen, prisma-kysely, hand-written) | DefaultTypeMap (node-postgres) |
@typesql/drizzle | a Drizzle database or transaction, on node-postgres or postgres.js | typeof schema: your pgTable, pgView, pgEnum, pgSchema exports | DefaultTypeMap (node-postgres) |
@typesql/prisma | Prisma Client or a transaction client | the typesql-prisma generator, or the generated client's Prisma.TypeMap | PrismaTypeMap ($queryRaw results) |
The client parameters are structural, so fakes work in tests without the driver.
ORM schemas
If your database is already described for Kysely, Drizzle or Prisma, you do not need to write the schema interface again. Their packages turn the ORM's types into the DB that createSql expects, so you can write SQL next to your ORM code, checked against the same tables.
The Kysely and Drizzle conversions happen only at the type level: there is no code generation and no database connection. Prisma's types leave out the database names and native types, so @typesql/prisma also has a generator that writes the exact schema.
The ORM's types say less than the database does, so each conversion has some gaps. They are listed on each package's page. Each package's createSql passes extra type arguments through, so you can fill those gaps in.
Running queries
Pass the client to createSql and a query runs itself:
ts
const sql = createSql<DB>(pool);
const posts = await sql("SELECT id, title FROM posts WHERE author_id = $1", 1).execute();| Method | Returns | Error |
|---|---|---|
q.execute() | Promise<Row[]> | — |
q.executeTakeFirst() | Promise<Row | undefined> | — |
q.executeTakeFirstOrThrow() | Promise<Row> | NoResultError (carries text) on 0 rows |
q.executeCount() | Promise<number> (row count) | rejects if the executor reports no count |
executeCount() returns the count Postgres reports for the statement: rows affected by INSERT, UPDATE, DELETE or MERGE, with or without RETURNING, rows returned by SELECT, and 0 for DDL. Every adapter reports it: rowCount (pg), count (postgres.js), numAffectedRows or the number of rows (Kysely), $executeRawUnsafe (Prisma), and for Drizzle the count of the driver underneath.
createSql(client, { rowKeys: "camelCase" }) gives camelCase rows, in the types and from execute* alike. See camelCase row keys.
The Row type flows from Query<Row, Params>, so core's RowType<Q> names it for your own helpers.
Transactions
sql.transaction(fn) runs fn with tx, the same sql bound to one transaction. It commits when fn resolves and rolls back when it throws (the error is rethrown); it resolves to fn's result. tx.transaction(fn) nests with a savepoint:
ts
const postId = await sql.transaction(async (tx) => {
// tx is sql bound to the transaction: same type, same helpers
const { id } = await tx("INSERT INTO posts (author_id, title) VALUES ($1, $2) RETURNING id", 7, "Draft").executeTakeFirstOrThrow();
try {
await tx.transaction(async (inner) => {
// a savepoint: when this throws, only what ran inside it is rolled back
await inner("UPDATE users SET display_name = $1 WHERE id = $2", "Author", 7).execute();
});
} catch {
// the post stays
}
return id;
});| Adapter | sql.transaction(fn) | tx.transaction(fn) |
|---|---|---|
@typesql/pg | on a Pool: checks out a client, BEGIN…COMMIT on it, releases it; on a Client or PoolClient: BEGIN…COMMIT on it | SAVEPOINT |
@typesql/postgres | sql.begin(fn) | tx.savepoint(fn) |
@typesql/kysely | db.transaction().execute(fn) | SAVEPOINT on the transaction |
@typesql/prisma | prisma.$transaction(fn) (interactive) | SAVEPOINT on the transaction client |
@typesql/drizzle | db.transaction(fn) | Drizzle's nested tx.transaction(fn) (a savepoint) |
A sql bound to a client that is already in a transaction (a postgres.js TransactionSql, a Kysely Transaction, a Prisma transaction client, a Drizzle tx) also uses a savepoint. Statements inside one transaction share one connection, so run them one after another, not with Promise.all.
Running on another client
Each execute* method also takes an executor, which wins over the bound one: executor(client) from the adapter wraps any client createSql accepts. Or bind a sql to that client:
ts
import { executor } from "@typesql/pg";
const client = await pool.connect(); // a client you manage yourself
try {
await sql("UPDATE users SET display_name = NULL WHERE id = $1", 7).execute(executor(client)); // on `client`
await createSql<DB>(client)("DELETE FROM posts WHERE author_id = $1", 7).execute(); // or bind a sql to it
} finally {
client.release();
}A sql from createSql<DB>() without a client builds queries (for types, tests or another runner); their execute* methods reject unless an executor is passed, and sql.transaction rejects.
Writing an executor
An Executor is (text, values) => PromiseLike<Row[] | { rows, rowCount }>. Return the rows alone when you have no count: everything except executeCount works, unless the executor also has a count(text, values) method, which executeCount then calls. rowCount: null means the statement has no count, which executeCount reads as 0. A wrapper around an adapter's executor can return its result unchanged:
ts
import { createSql as createCoreSql, type Executor } from "@typesql/core";
import type { PgTypeMap } from "@typesql/pg";
const onPool = executor(pool);
const logged: Executor = async (text, values) => {
const started = performance.now();
try {
return await onPool(text, values); // { rows, rowCount }: executeCount keeps working
} finally {
console.debug(`${Math.round(performance.now() - started)} ms`, text);
}
};
const sqlLogged = createCoreSql<DB, PgTypeMap>(logged);Executors that transform rows
The row types describe what the driver returns for the SQL: the column names Postgres gives and the type map's value types. execute* hands back the rows the executor returns, unchanged (apart from rowKeys). An executor that reshapes rows, such as one that keeps Kysely plugins or post-processes values, makes the types wrong. Pick the narrowest fix:
- camelCase keys: use
rowKeys: "camelCase"and keep the executor plain. With Kysely, use@typesql/kysely'screateSql(db, { rowKeys: "camelCase" })orexecutor(db), which bypass plugins, instead of an executor that runsCamelCasePlugin. - Different value types from your driver's parsers (say
timestamptzparsed to a string): use a type map that says so:createSql<DB, MyTypeMap>(executor). - Anything else: keep the executor plain and transform in a typed helper built on
RowType<Q>:
ts
import type { Query } from "@typesql/core";
/** Rows as they go out as JSON: dates become ISO strings. */
type Wire<Row> = { [K in keyof Row]: Row[K] extends Date ? string : Row[K] };
const toWire = (row: object) => Object.fromEntries(Object.entries(row).map(([k, v]) => [k, v instanceof Date ? v.toISOString() : v]));
async function wire<Q extends Query<object>>(q: Q): Promise<Wire<RowType<Q>>[]> {
return (await q.execute()).map(toWire) as Wire<RowType<Q>>[];
}
const payload = await wire(sql("SELECT id, created_at FROM users")); // { id: number; created_at: string }[]Augmenting Query also works, and changes the return type of execute* for every query in the program:
ts
declare module "@typesql/core" {
interface Query<Row, Params extends readonly unknown[] = readonly unknown[], Text extends string = string> {
execute(executor?: Executor): Promise<Wire<Row>[]>;
executeTakeFirst(executor?: Executor): Promise<Wire<Row> | undefined>;
executeTakeFirstOrThrow(executor?: Executor): Promise<Wire<Row>>;
}
}It is global and easy to get wrong. The type parameters must repeat core's exactly (names, constraints and defaults). The new signatures only win because TypeScript puts overloads from a later declaration first. RowType<Q> still describes the untransformed row. Prefer the options above. If you do augment, make sure the executor's transform and the declared type match.
Driver differences at a glance
| Postgres type | pg default | postgres.js default | postgres.js + BigInt |
|---|---|---|---|
int8 (count(*), bigserial) | string | string | bigint |
numeric | string | string | string |
date, timestamp, timestamptz | Date | Date | Date |
interval | PgInterval object | string | string |
point, circle | object | string | string |
bytea | Buffer | Buffer | Buffer |
json, jsonb | parsed (unknown) | parsed | parsed |
numeric[] | number[] (typed string[]) | string[] | string[] |
| unparsed array types | raw string | element-wise array | element-wise array |
The full tables are on each page: PgTypeMap, PostgresJsTypeMap and PrismaTypeMap. Kysely and Drizzle run on one of the two drivers; see their pages for the type map to pass.