Skip to content

Kysely ​

@typesql/kysely converts the DB interface you give Kysely into a TypeSQL schema and runs queries on your Kysely instance. The conversion happens only at the type level: there is no code generation and no database connection.

sh
pnpm add @typesql/core @typesql/kysely kysely

Pass the DB interface you give to new Kysely<DB>():

ts
// db.ts: kysely-codegen output (trimmed), or the interface you wrote for Kysely
import type { ColumnType, Generated, GeneratedAlways } from "kysely";

export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
export type Numeric = ColumnType<string, number | string, number | string>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export type Mood = "happy" | "sad";

export interface DB {
  users: {
    id: Generated<number>;
    email: string;
    mood: Mood | null;
    balance: Numeric;
    created_at: Generated<Timestamp>;
  };
  posts: {
    id: GeneratedAlways<Int8>;
    author_id: number;
    title: string;
    published: Generated<boolean>;
    score: number | null;
  };
  "auth.sessions": { id: Generated<Int8>; user_id: number; expires_at: Timestamp };
}
ts
import { createSql } from "@typesql/kysely";

const sql = createSql<DB>(db); // db: your Kysely<DB> instance (or a transaction)

const q = sql(
  `SELECT u.email, u.mood, p.id, p.title
   FROM users u JOIN posts p ON p.author_id = u.id
   WHERE u.mood = $1 AND p.published`,
  "happy",
);
// Query<{ email: string; mood: "happy" | "sad"; id: string; title: string }, ["happy" | "sad"]>

sql("SELECT s.expires_at FROM auth.sessions s WHERE s.user_id = $1", 1); // "auth.sessions" key
sql("INSERT INTO posts (author_id, title) VALUES ($1, $2) RETURNING id", 1, "Hello"); // Generated: optional

Kysely describes a column by its TypeScript type, so the Postgres type is read back from that type:

Kysely column typePostgres type
number / string / boolean / bigintint4 / text / bool / int8
Date, codegen's Timestamptimestamptz
codegen's Int8 / Numericint8 / numeric
Buffer, Uint8Arraybytea
Interval, Point, Circleinterval, point, circle
Json, JSONColumnType<T>, other objectsjsonb
T[], ArrayType<T>element type []
a union of string literals (codegen enums)an enum, registered in $types
T | nullnullable
Generated<T>, GeneratedAlways<T>has a default: optional in INSERT
"auth.users" keyauth.users

Overrides ​

That mapping is lossy: number also stands for int2, float4 and float8, string also stands for uuid, varchar and time, and Timestamp also stands for date and timestamp. Row types are the same either way. Expression types differ: division, aggregates, date arithmetic. Pin the columns where it matters with the third type argument, keyed by the interface's table and column keys:

ts
const sqlx = createSql<DB, DefaultTypeMap, {
  posts: { score: "float8" }; // codegen emits `number` for float8; inferred as int4
  $types: { mood: Mood }; // name the enum: columns with exactly these values share it
}>(db);

sql("SELECT avg(score) FROM posts"); // avg(int4) is numeric: { avg: string | null }
sqlx("SELECT avg(score) FROM posts"); // avg(float8) is float8: { avg: number | null }

To get the exact types without listing overrides, generate the schema from the database instead (Introspection) and keep Kysely for the queries it builds.

An override is a type name ("float8") or a partial descriptor ({ type?, nullable?, hasDefault? }). { $view: true } marks a table as a view, so writes to it are rejected. Without $types, each enum column gets its own name (users.mood). Two columns then count as different enums, and UNIONing them is an error, just as it would be for two different Postgres enums.

camelCase ​

For an interface generated with kysely-codegen --camel-case (used with CamelCasePlugin), pass { camelCase: true }. SQL and rows then use the database's snake_case names:

ts
interface CamelDB {
  blogPosts: { id: Generated<number>; authorId: number; createdAt: Generated<Timestamp> };
}

const camel = createSql<CamelDB, DefaultTypeMap, {}, { camelCase: true }>(camelDb);
camel("SELECT author_id, created_at FROM blog_posts WHERE id = $1", 1);
// Query<{ author_id: number; created_at: Date }, [number]>

For camelCase rows, as CamelCasePlugin would return them, pass { rowKeys: "camelCase" }. The top-level keys are renamed with the plugin's mapping, in the types and at runtime. The plugin itself stays bypassed, so keys inside JSON values are not renamed. See camelCase row keys.

ts
const camelRows = createSql<CamelDB, DefaultTypeMap, {}, { camelCase: true }>(camelDb, { rowKeys: "camelCase" });
const post = await camelRows("SELECT author_id, created_at FROM blog_posts WHERE id = $1", 1).executeTakeFirstOrThrow();
// { authorId: number; createdAt: Date }

Running queries ​

ts
const drafts = await sql("SELECT id, title FROM posts WHERE NOT published").execute();
// { id: string; title: string }[]

const published = await sql.transaction((tx) =>
  tx("UPDATE posts SET published = true WHERE author_id = $1", 1).executeCount(),
); // number of affected rows

// inside a Kysely transaction, bind a sql to it to mix builder and SQL queries
await db.transaction().execute(async (trx) => {
  await trx.updateTable("posts").set({ published: false }).where("author_id", "=", 1).execute();
  await createSql<DB>(trx)("DELETE FROM posts WHERE author_id = $1 AND NOT published", 1).execute();
});

The query is sent as CompiledQuery.raw(text, values) through db.withoutPlugins(). Logging, the dialect and the pool still apply, but result transformers such as CamelCasePlugin do not, so rows keep the keys their type says they have. sql.transaction(fn) runs in db.transaction().execute(...), and nests with a savepoint. Kysely's PostgresDialect runs on pg. For the exact pg types (Buffer for bytea, interval objects), pass PgTypeMap as the second type argument: createSql<DB, PgTypeMap>(db).

Limits ​

  • JSON columns always count as nullable, because codegen's JsonValue includes null. Override { nullable: false } to have INSERT require them.
  • A column only has a default if it is declared Generated/GeneratedAlways. Mark other columns with { hasDefault: true }.
  • Views look like tables until you mark them with $view: true.