Kysely の使い方 — TypeScript 型安全 SQL クエリビルダー完全ガイド
一言でいうと
Kysely(キーセリー)は、TypeScript の型システムを最大限に活用した SQL クエリビルダーです。テーブル名・カラム名・結果型をすべてコンパイル時に検証し、IDE の自動補完も完全に効く状態で SQL を組み立てられます。
どんな時に使う?
- ORM は重すぎるが、生 SQL は型安全性がなくて不安 — SQL の書き心地を保ちつつ、型の恩恵を受けたいとき
- 複雑なクエリを安全に組み立てたい — サブクエリ、JOIN、CTE(WITH句)などを多用するアプリケーションで、型の整合性を保証したいとき
- マルチDB対応のバックエンドを構築したい — PostgreSQL、MySQL、SQLite、MS SQL Server など複数の DB に対応する必要があるとき
インストール
# npm
npm install kysely
# yarn
yarn add kysely
# pnpm
pnpm add kysely
DB ドライバーは別途インストールが必要です。
# PostgreSQL
npm install pg
# または
npm install postgres
# MySQL
npm install mysql2
# SQLite (better-sqlite3)
npm install better-sqlite3
# MS SQL Server
npm install tedious tarn
基本的な使い方
1. データベーススキーマの型定義
Kysely の型安全性の根幹は、データベースのスキーマを TypeScript の型として定義することにあります。
import {
ColumnType,
Generated,
Insertable,
Selectable,
Updateable,
} from 'kysely'
// データベース全体の型定義
interface Database {
person: PersonTable
pet: PetTable
}
interface PersonTable {
id: Generated<number> // INSERT 時に省略可能(auto increment)
first_name: string
last_name: string | null
created_at: ColumnType<Date, string | undefined, never>
// ColumnType<SelectType, InsertType, UpdateType>
// → SELECT 時は Date、INSERT 時は string | undefined、UPDATE 不可
}
interface PetTable {
id: Generated<number>
name: string
owner_id: number
species: 'dog' | 'cat' | 'hamster'
}
// ヘルパー型(INSERT/UPDATE 用)
type Person = Selectable<PersonTable>
type NewPerson = Insertable<PersonTable>
type PersonUpdate = Updateable<PersonTable>
2. Kysely インスタンスの作成
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
host: 'localhost',
database: 'my_app',
user: 'postgres',
password: 'password',
}),
}),
})
3. クエリの実行
// SELECT
const persons = await db
.selectFrom('person')
.select(['id', 'first_name', 'last_name'])
.where('first_name', '=', 'John')
.execute()
// persons の型: { id: number; first_name: string; last_name: string | null }[]
// INSERT
const result = await db
.insertInto('person')
.values({
first_name: 'Jane',
last_name: 'Doe',
created_at: new Date().toISOString(),
})
.returningAll()
.executeTakeFirstOrThrow()
// UPDATE
await db
.updateTable('person')
.set({ last_name: 'Smith' })
.where('id', '=', 1)
.execute()
// DELETE
await db
.deleteFrom('pet')
.where('id', '=', 42)
.execute()
よく使う API — Kysely の主要メソッドの使い方
1. selectFrom + where — 条件付き SELECT
// 基本的な WHERE
const adults = await db
.selectFrom('person')
.selectAll()
.where('first_name', 'like', '%John%')
.execute()
// 複数条件(AND)
const result = await db
.selectFrom('person')
.select(['id', 'first_name'])
.where('first_name', '=', 'John')
.where('last_name', 'is not', null)
.execute()
// OR 条件・ネストした条件
const result2 = await db
.selectFrom('person')
.selectAll()
.where((eb) =>
eb.or([
eb('first_name', '=', 'John'),
eb.and([
eb('first_name', '=', 'Jane'),
eb('last_name', '=', 'Doe'),
]),
])
)
.execute()
2. innerJoin / leftJoin — テーブル結合
const petsWithOwners = await db
.selectFrom('pet')
.innerJoin('person', 'person.id', 'pet.owner_id')
.select([
'pet.name as pet_name',
'person.first_name as owner_name',
'pet.species',
])
.execute()
// 型: { pet_name: string; owner_name: string; species: 'dog' | 'cat' | 'hamster' }[]
// LEFT JOIN の場合、結合先のカラムは自動的に nullable になる
const petsWithOptionalOwner = await db
.selectFrom('pet')
.leftJoin('person', 'person.id', 'pet.owner_id')
.select(['pet.name', 'person.first_name'])
.execute()
// person.first_name の型は string | null になる
3. insertInto — 一括挿入と RETURNING
// 複数行の一括挿入
await db
.insertInto('pet')
.values([
{ name: 'Buddy', owner_id: 1, species: 'dog' },
{ name: 'Whiskers', owner_id: 2, species: 'cat' },
])
.execute()
// INSERT ... ON CONFLICT(UPSERT)— PostgreSQL
await db
.insertInto('person')
.values({
id: 1,
first_name: 'John',
last_name: 'Doe',
})
.onConflict((oc) =>
oc.column('id').doUpdateSet({
first_name: 'John',
last_name: 'Doe',
})
)
.execute()
4. with — CTE(共通テーブル式)
const result = await db
.with('dog_owners', (qb) =>
qb
.selectFrom('pet')
.innerJoin('person', 'person.id', 'pet.owner_id')
.where('pet.species', '=', 'dog')
.select(['person.id', 'person.first_name'])
)
.selectFrom('dog_owners')
.selectAll()
.execute()
// CTE の結果型も正しく推論される
5. transaction — トランザクション
const result = await db.transaction().execute(async (trx) => {
const person = await trx
.insertInto('person')
.values({
first_name: 'Alice',
last_name: 'Wonderland',
})
.returningAll()
.executeTakeFirstOrThrow()
await trx
.insertInto('pet')
.values({
name: 'Cheshire',
owner_id: person.id,
species: 'cat',
})
.execute()
return person
})
// トランザクション内でエラーが発生すると自動ロールバック
6. sql テンプレートタグ — 生 SQL のエスケープハッチ
import { sql } from 'kysely'
// 型安全なクエリに生 SQL を混ぜる
const result = await db
.selectFrom('person')
.select([
'first_name',
sql<number>`extract(year from created_at)`.as('created_year'),
])
.where(sql`lower(first_name)`, '=', 'john')
.execute()
// created_year の型は number として推論される
// 完全な生 SQL クエリ
const rawResult = await sql<Person>`
SELECT * FROM person WHERE id = ${someId}
`.execute(db)
類似パッケージとの比較
| 特徴 | Kysely | Knex.js | Prisma | Drizzle ORM |
|---|---|---|---|---|
| 型安全性 | ◎ コンパイル時に完全検証 | △ 型定義は手動 | ○ スキーマから生成 | ◎ コンパイル時に検証 |
| SQL への近さ | ◎ SQL をほぼそのまま書ける | ○ SQL ライク | △ 独自 API | ○ SQL ライク |
| マイグレーション | △ 別パッケージ or 自前 | ◎ 組み込み | ◎ 組み込み | ◎ 組み込み |
| バンドルサイズ | ◎ 軽量(コアのみ) | ○ 中程度 | △ 大きい | ○ 軽量 |
| 学習コスト | ○ SQL を知っていれば低い | ○ 低い | ○ 独自概念あり | ○ SQL を知っていれば低い |
| エッジランタイム対応 | ◎ Deno/Bun/CF Workers | △ Node.js 中心 | △ 制限あり | ○ 対応 |
| 生 SQL の扱い | ◎ sql タグで型付き | ○ raw メソッド | △ $queryRaw | ○ sql タグ |
| 設計思想 | クエリビルダー | クエリビルダー | ORM | ORM + クエリビルダー |
選定の目安:
- SQL を直接書く感覚で型安全にしたい → Kysely
- マイグレーションやリレーション管理も含めた統合環境が欲しい → Prisma / Drizzle
- 既存の Knex プロジェクトに型安全性を加えたい → Kysely(Knex にインスパイアされた API)
注意点・Tips
スキーマ型の自動生成を活用する
手動でスキーマ型を書くのは面倒でミスの元です。kysely-codegen を使えば、既存の DB スキーマから型定義を自動生成できます。
npm install -D kysely-codegen
npx kysely-codegen --dialect postgres
executeTakeFirst と executeTakeFirstOrThrow の使い分け
// 結果が 0 件の可能性がある場合 → undefined を含む型が返る
const maybePerson = await db
.selectFrom('person')
.selectAll()
.where('id', '=', 999)
.executeTakeFirst()
// 型: Person | undefined
// 必ず 1 件あるはずの場合 → 0 件なら例外をスロー
const person = await db
.selectFrom('person')
.selectAll()
.where('id', '=', 1)
.executeTakeFirstOrThrow()
// 型: Person
動的なクエリ構築
条件に応じてクエリを組み立てたい場合は $if を使います。
function findPersons(criteria: {
firstName?: string
lastName?: string
}) {
let query = db.selectFrom('person').selectAll()
if (criteria.firstName) {
query = query.where('first_name', '=', criteria.firstName)
}
if (criteria.lastName) {
query = query.where('last_name', '=', criteria.lastName)
}
return query.execute()
}
マイグレーション
Kysely にはシンプルなマイグレーション機能が組み込まれています。
import { Kysely, Migration, MigrationProvider } from 'kysely'
const migration: Migration = {
async up(db: Kysely<any>) {
await db.schema
.createTable('person')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('first_name', 'varchar(255)', (col) => col.notNull())
.addColumn('last_name', 'varchar(255)')
.addColumn('created_at', 'timestamp', (col) =>
col.defaultTo(sql`now()`).notNull()
)
.execute()
},
async down(db: Kysely<any>) {
await db.schema.dropTable('person').execute()
},
}
destroy を忘れない
アプリケーション終了時にコネクションプールを適切に破棄してください。
await db.destroy()
ハマりやすいポイント
Generated型を忘れると INSERT 時にエラーになる — auto increment や default 値を持つカラムには必ずGenerated<T>を使いましょうColumnTypeの3つの型引数の順番 —ColumnType<SelectType, InsertType, UpdateType>です。混同しやすいので注意- PostgreSQL の
returningは MySQL では使えない — DB ごとの SQL 方言の違いに注意。Kysely は方言に応じて型レベルでメソッドの有無を制御します
まとめ
Kysely は「SQL を書ける人がそのまま型安全に書ける」という絶妙なポジションのク