home

Blog


Ensuring type safe database queries and migrations with Node


Talking to a database with Node usually goes one of two ways: you write raw SQL and trust that the strings in your code still match the columns in the database, while dealing with SQL injections and migrations by yourself, or you pull in a full ORM and inherit an entire opinionated world just to run simple queries. Both work fine until someone renames a column and you find out about it in production, or you want to do something slightly different and your ORM just won't let you. Fortunately, there's a middle ground where you can get the best of both worlds.

Writing queries with Kysely

Kysely is a query builder, not an ORM. There's no entity layer, no lazy loading, no magic session flushing things behind your back. You describe your schema once as a TypeScript interface and from there every method on the builder narrows what you're allowed to do next.

import { Kysely, PostgresDialect, Generated } from 'kysely'
import { Pool } from 'pg'

interface UsersTable {
  id: Generated<number>
  email: string
  name: string | null
  created_at: Generated<Date>
}

interface Database {
  users: UsersTable
}

export const db = new Kysely<Database>({
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL }),
  }),
})

It's worth pointing out that Generated<T> marks columns the database fills in for you, like a serial primary key. When inserting data, the field is optional, but when you select it, it is always there. This is exactly what you want to prevent mistakes when writing code and it's where handwritten interfaces usually fail.

Actually querying something

const users = await db
  .selectFrom('users')
  .select(['id', 'email', 'created_at'])
  .where('email', 'like', '%@lucastonelli.com')
  .orderBy('created_at', 'desc')
  .limit(10)
  .execute()

// users: { id: number; email: string; created_at: Date }[]

The return type isn't something you annotated, it's inferred from what you selected. If you ask for two columns, you get a type with two columns. Make a typo (emial) and the build fails before anything reaches a database. Join another table and the autocomplete for where immediately knows about the new columns.

Kysely also ships Selectable, Insertable and Updateable helpers, so your service functions can take the right shape without you maintaining three nearly identical interfaces by hand.

import { Insertable } from 'kysely'

async function createUser(user: Insertable<UsersTable>) {
  return await db
    .insertInto('users')
    .values(user)
    .returningAll()
    .executeTakeFirstOrThrow()
}

Introspecting the database and generating the types

Everything above depends on that Database interface being correct. Writing it by hand is fine for three tables, but for thirty, it's tedious and it goes stale the moment a teammate merges a migration. At that point your types are no longer describing your database, they're describing your optimism.

The package kysely-codegen fixes that by connecting to the database, introspecting it and writing the types for you. You can start by installing it.

npm install --save-dev kysely-codegen

Then point it at a connection string (it reads DATABASE_URL from your .env by default) and run it.

kysely-codegen --out-file ./src/db/types.ts

VoilĂ , your types were generated! By default, the output lands in ./node_modules/kysely-codegen/dist/db.d.ts, which means the types vanish on a clean install and nobody can see them in a pull request, so the --out-file flag matters. Writing them into src and committing them means schema changes show up in the diff, where a human can actually look at them.

From there, the wiring barely changes:

import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
import { DB } from './db/types'

export const db = new Kysely<DB>({
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL }),
  }),
})

There are a few options worth knowing about: --camel-case if you'd rather not use underscores everywhere (because of ESLint); --singularize to get User instead of Users. And you can have a config file (.kysely-codegenrc.ts, or a kysely-codegen key in package.json) so the whole team runs it the same way instead of everyone inventing their own flags.

Migrations

Generated types are only as good as the database they were generated from, so migrations need to be part of the same flow. Kysely exposes a Migrator class you can wire up by hand, but there's no reason to write that script yourself anymore since kysely-ctl is the official CLI and it does the wiring for you. It also mirrors Knex.js' CLI, so if you've used Knex before, the commands will feel suspiciously familiar.

npm install --save-dev kysely-ctl

Once installed in the project, it becomes available as kysely and a kysely.config.ts file will be required to use it, either in the project root or inside a .config folder. You can write it yourself, but the CLI will scaffold one for you by running kysely init.

import { defineConfig } from 'kysely-ctl'

export default defineConfig({
  dialect: 'pg',
  dialectConfig: {
    connectionString: process.env.DATABASE_URL,
  },
  migrations: {
    migrationFolder: './src/db/migrations',
  },
})

Notice that dialect here is just the name of the driver library, and dialectConfig gets handed to the matching Kysely dialect, so you don't have to instantiate anything - or just configure it from scratch here, it's fine either way. But if you'd rather not configure the connection twice, you can skip both and pass the kysely instance your app already exports instead.

Creating a migration

No more inventing your own file naming convention and then arguing about it in code review.

kysely migrate:make create_users_table

That drops a timestamped TypeScript file into your migration folder. There's a default prefix, but it can be customized, along with other settings in your config file.

import { Kysely, sql } from 'kysely'

export async function up(db: Kysely<any>): Promise<void> {
  await db.schema
    .createTable('users')
    .addColumn('id', 'serial', (col) => col.primaryKey())
    .addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
    .addColumn('name', 'varchar(255)')
    .addColumn('created_at', 'timestamptz', (col) =>
      col.defaultTo(sql`now()`).notNull()
    )
    .execute()
}

export async function down(db: Kysely<any>): Promise<void> {
  await db.schema.dropTable('users').execute()
}

Yes... that's an any typing, and it's intentional, don't judge me (yet). A migration runs against whatever the schema looked like at that point in history, not against today's generated types. If you type it with your current DB, the file will keep compiling for a while and then start lying to you the moment a later migration drops that table. Kysely recommends any here and this is one of the rare places where it's the honest answer.

Running migrations

Every command can be written with a colon or a space, so kysely migrate:latest and kysely migrate latest do the same thing.

# run every pending migration
kysely migrate:latest

# see what has run and what hasn't
kysely migrate:list

# step back one migration
kysely migrate:down

# undo everything (the --all is not optional)
kysely migrate:rollback --all

That --all is the one gotcha worth remembering: a partial rollback isn't supported, because Kysely doesn't keep track of migration batches the way Knex does. If you want to undo a single migration, migrate:down is your friend. The good part is that the CLI reports each migration and its status for you, so you no longer have to remember that migrateToLatest never throws and quietly hands you an error object your deployment script forgot to check.

Under the hood it's still Kysely's own migrator, which means migrations take a database-level lock. Calling them from multiple instances at once is safe since they run serially and each migration executes exactly once.

As a bonus, the same CLI gives you seeds (kysely seed:make and kysely seed:run) and a kysely sql command that runs queries through your configured instance, which is handy when you just want to poke at something without opening a client.

Migrate first, then generate

The order matters, and it's the whole point of this setup. The database is the source of truth, the types are a derived artifact.

{
  "scripts": {
    "db:migrate": "kysely migrate:latest",
    "db:types": "kysely-codegen --out-file ./src/db/types.ts",
    "db:sync": "npm run db:migrate && npm run db:types"
  }
}

Run db:sync locally after writing a migration, commit both the migration and the regenerated types, and let CI type-check the result. If someone changes the schema and forgets to regenerate, the diff will look wrong to a reviewer. If someone regenerates the types and a query no longer compiles, that's not an annoyance, that's the tool doing its job and telling you a rename broke something.

Wrap-up

None of this is a framework and none of it asks you to give up SQL. Kysely types the queries you were already writing, kysely-codegen makes the database itself the source of those types, and kysely-ctl keeps the schema moving forward in a way you can replay and roll back. The library feels like a breath of fresh air among other options, fully integrated with TypeScript, helping us keep our sanity.

Powered by Simple Blog API