Skip to content

Your schema

Type your Postgres tables for the kysely plugin by declaring a schema on KyselyDatabase. Covers the column helpers, what an undeclared schema lets through, and keeping the schema in step with your migrations.

TypeScript can't read your tables out of Postgres while it compiles, so Kysely types every query against an interface you write by hand. To give the plugin that interface, declare schema on KyselyDatabase. The plugin's connection and every service get their table and column types from it.

src/database.ts
import type { ColumnType, Generated } from 'kysely';

export interface UsersTable {
    id: Generated<number>;
    discord_id: string;
    username: string;
    joined_at: ColumnType<Date, string | undefined, never>;
}

export interface AppDatabase {
    users: UsersTable;
}

declare module '@seedcord/plugin-kysely-postgres' {
    interface KyselyDatabase {
        schema: AppDatabase;
    }
}

TypeScript applies that declare module block from any file your tsconfig includes, so nothing needs to import src/database.ts.

AppDatabase takes one entry per table, keyed by the table's name in Postgres.

Most columns have one type whether you select, insert, or update them, like username. For the ones that don't, Kysely has two helpers. Generated is for a column Postgres fills in itself, like a serial or identity id. ColumnType takes three types, one each for select, insert, and update. Here's what UsersTable ends up allowing:

columnselect returnsinsert takesupdate takes
usernamestringstringstring
idnumbernumber, or leave it outnumber
joined_atDatestring, or leave it outnever, so TypeScript rejects an update that sets it

Every table name compiles until you declare it

With KyselyDatabase left empty, KyselySchema falls back to Record<string, never>. That widens KyselyTable to plain string.

import type { KyselyTable } from '@seedcord/plugin-kysely-postgres';

declare const table: KyselyTable;
const table: string

A typo like selectFrom('user') then compiles. Postgres rejects the query because no user table exists, so you only find the typo when someone runs that command and it fails for them. A misspelled column name compiles the same way. Every column you select is typed never. TypeScript accepts a never value anywhere, so it can't report a column used as the wrong type either.

What the declaration catches

Once you declare the schema, that typo fails to compile.

await this.core.db.connection
    .selectFrom('user')
Argument of type '"user"' is not assignable to parameter of type 'TableExpressionOrList<{ users: UsersTable; }, never>'.
.selectAll() .execute();

'user' is missing its s. TypeScript reports it before the query can reach Postgres. With the name spelled users, the same query compiles.

hover for typestap for types, arrow keys walk the tokens
const 
const row:
    | {
          id: number;
          username: string;
      }
    | undefined
row
= await this.BaseHandler<ChatInputCommandInteraction<"cached">, Core>.core: Corecore.Core.db: KyselyPostgresdb.
KyselyPostgres.connection: Kysely<{
    users: UsersTable;
}>
connection
.
QueryCreator<{ users: UsersTable; }>.selectFrom<"users">(from: "users"): SelectQueryBuilder<{
    users: UsersTable;
}, "users", {}>
selectFrom
('users')
.
SelectQueryBuilder<{ users: UsersTable; }, "users", {}>.select<"id" | "username">(selections: readonly ("id" | "username")[]): SelectQueryBuilder<{
    users: UsersTable;
}, "users", {
    id: number;
    username: string;
}> (+2 overloads)
select
(['id', 'username'])
.
SelectQueryBuilder<{ users: UsersTable; }, "users", { id: number; username: string; }>.executeTakeFirst(options?: AbortableQueryOptions): Promise<{
    id: number;
    username: string;
} | undefined>
executeTakeFirst
();
await this.RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, BufferResolvable | Stream | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.reply(response: string | ReplyResponse<BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>, opts?: SendOpts): Promise<SentMessage>reply(
const row:
    | {
          id: number;
          username: string;
      }
    | undefined
row
?
const row: {
    id: number;
    username: string;
}
row
.username: stringusername : 'No record.');

row has the shape of a users row, with id typed as number since the select unwraps its Generated. executeTakeFirst() returns undefined when no row matches, so the reply checks row before it reads username.

Nothing checks this against your database

You write this interface by hand, separately from the migrations that create the tables. When a migration adds a column, your queries can't select it until you add it to this interface too. Every schema change is an edit in two places. If you misspell a column here, the query still compiles, though it fails when it runs.

kysely-codegen (opens in a new tab) saves you the second edit. It connects to your database, reads the tables your migrations created, and writes a file that exports an interface named DB.

pnpm add -D kysely-codegen
pnpm exec kysely-codegen --out-file src/db.d.ts

The command reads the connection string from DATABASE_URL, either in your environment or in a .env file. Point schema at the generated type, and delete the interfaces you wrote by hand.

src/database.ts
import type { DB } from './db';

declare module '@seedcord/plugin-kysely-postgres' {
    interface KyselyDatabase {
        schema: DB;
    }
}

The generated file only changes when you run the command again, so run it after every migration.

To keep each table's queries in one class, write services. Migrations create the tables this interface describes.