Skip to content

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:

  1. a type map describing how that driver's default parsers turn each Postgres type into a JS value;
  2. a createSql<DB>(client) that uses that map and runs the queries on client, with sql.transaction(fn) for transactions.
PackageRuns queries withSchema fromDefault type map
@typesql/pgnode-postgres 8.x: Pool, Client, PoolClientyour DB interfacePgTypeMap
@typesql/postgrespostgres.js 3.x: Sql, TransactionSql, ReservedSqlyour DB interfacePostgresJsTypeMap, PostgresJsBigIntTypeMap
@typesql/kyselya Kysely or Transactionyour Kysely DB interface (kysely-codegen, prisma-kysely, hand-written)DefaultTypeMap (node-postgres)
@typesql/drizzlea Drizzle database or transaction, on node-postgres or postgres.jstypeof schema: your pgTable, pgView, pgEnum, pgSchema exportsDefaultTypeMap (node-postgres)
@typesql/prismaPrisma Client or a transaction clientthe typesql-prisma generator, or the generated client's Prisma.TypeMapPrismaTypeMap ($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();
MethodReturnsError
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;
});
Adaptersql.transaction(fn)tx.transaction(fn)
@typesql/pgon a Pool: checks out a client, BEGIN…COMMIT on it, releases it; on a Client or PoolClient: BEGIN…COMMIT on itSAVEPOINT
@typesql/postgressql.begin(fn)tx.savepoint(fn)
@typesql/kyselydb.transaction().execute(fn)SAVEPOINT on the transaction
@typesql/prismaprisma.$transaction(fn) (interactive)SAVEPOINT on the transaction client
@typesql/drizzledb.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:

  1. camelCase keys: use rowKeys: "camelCase" and keep the executor plain. With Kysely, use @typesql/kysely's createSql(db, { rowKeys: "camelCase" }) or executor(db), which bypass plugins, instead of an executor that runs CamelCasePlugin.
  2. Different value types from your driver's parsers (say timestamptz parsed to a string): use a type map that says so: createSql<DB, MyTypeMap>(executor).
  3. 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 typepg defaultpostgres.js defaultpostgres.js + BigInt
int8 (count(*), bigserial)stringstringbigint
numericstringstringstring
date, timestamp, timestamptzDateDateDate
intervalPgInterval objectstringstring
point, circleobjectstringstring
byteaBufferBufferBuffer
json, jsonbparsed (unknown)parsedparsed
numeric[]number[] (typed string[])string[]string[]
unparsed array typesraw stringelement-wise arrayelement-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.