Appearance
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 kyselyPass 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: optionalKysely describes a column by its TypeScript type, so the Postgres type is read back from that type:
| Kysely column type | Postgres type |
|---|---|
number / string / boolean / bigint | int4 / text / bool / int8 |
Date, codegen's Timestamp | timestamptz |
codegen's Int8 / Numeric | int8 / numeric |
Buffer, Uint8Array | bytea |
Interval, Point, Circle | interval, point, circle |
Json, JSONColumnType<T>, other objects | jsonb |
T[], ArrayType<T> | element type [] |
| a union of string literals (codegen enums) | an enum, registered in $types |
T | null | nullable |
Generated<T>, GeneratedAlways<T> | has a default: optional in INSERT |
"auth.users" key | auth.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
JsonValueincludesnull. Override{ nullable: false }to haveINSERTrequire 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.