「TypeScript で DB 層を作る」と言うと、まず ORM の名前が挙がります。しかし案件によっては、ORM が提供する抽象そのものが要らないことがあります。集計とウィンドウ関数が主役の分析系、DB 固有機能に踏み込む必要がある基盤、あるいは「SQL は読めるチームなので、型だけ守ってくれればいい」という現場です。
その領域にKyselyがあります。よく「軽量な ORM」と紹介されますが、それは正確ではありません。Kysely は ORM ではなく、型安全な SQL クエリビルダです。この違いは好みの問題ではなく、何を自分で持ち続ける責任があるかの違いです。
この記事は、Kysely を本番で使うかどうかを判断し、使うなら壊れにくく組むための設計ガイドです。4ツールの横並び比較はPrisma vs Drizzle vs TypeORM vs Kysely 技術選定ガイドに譲り、ここでは Kysely 単体を本番運用する視点に絞ります。
この記事のルール:バージョン・依存関係・API の挙動は、すべて GitHub の実ソース(
master、v0.29.5 時点) で確認した事実のみを書きます。要約サイトや二次情報の数値は使いません。最新は必ず公式で確認してください。
0. 結論を先に:Kysely が正解になる条件
3つすべてに当てはまるなら Kysely が噛み合います。ひとつでも外れるなら ORM を選んだほうが幸せです。
| 条件 | 当てはまる | 当てはまらない |
|---|---|---|
| SQL を書くこと自体が要件 | 複雑な集計・ウィンドウ関数・DB固有機能を使う | CRUD が大半で、SQL を書きたくない |
| リレーションの自動解決が不要 | JOIN は自分で書くほうが速い・読める | ネストした関連データを宣言的に取りたい |
| 型の真実源を自分で管理できる | codegen を CI に組み込む運用ができる | スキーマ定義から全部を導出したい |
Kysely は「ORM を薄くしたもの」ではなく、ORM を使わないという選択の受け皿です。ここを取り違えると、後から「リレーションが面倒」「スキーマ管理が手薄」と言って ORM に戻ることになります。
1. 素性を package.json で確認する
技術選定でいちばん誤情報が多いのがここです。実際のマニフェストを見ます。
// kysely-org/kysely — package.json(v0.29.5)より、選定に効くキーだけ抜粋
{
"version": "0.29.5",
"engines": { "node": ">=22.0.0" },
"sideEffects": false // tree-shaking 可能
// dependencies / peerDependencies は「空」ではなく、キー自体が存在しない。
// devDependencies のみ。= 実行時依存ゼロ。
}
ここから読み取れる、選定に直結する事実が4つあります。
① ゼロ依存。 dependencies が空です。サプライチェーン上の攻撃面が実質 Kysely 自身だけになるのは、監査対象を減らしたい案件では実利があります。DB ドライバ(pg、mysql2 等)は利用側が直接持つ形になります。
② engines.node が >=22.0.0。 これは見落とされがちですが重い制約です。Node 20 LTS で動かしているサービスでは、0.29.5 は選定候補から外れます。既存環境の Node を上げられるかを先に確認してください。
③ sideEffects: false。 バンドラが未使用コードを落とせます。エッジ/サーバーレスでバンドルサイズが効く場面での利点です。
④ バージョンが 0.x。 1.0 に到達していません。セマンティックバージョニングの慣習上、マイナー更新に破壊的変更が入りうる領域です。実務上は安定して使われていますが、「1.0 未満である」という事実は選定会議で共有すべきで、隠すべきものではありません。バージョンを固定し、更新時は CHANGELOG を読む運用が前提になります。
組み込みダイアレクト
src/dialect/ にあるのは PostgreSQL / MySQL / MSSQL / SQLite / PGlite の5つです。PGlite(WASM 版 PostgreSQL)が含まれるのは比較的新しく、テストをブラウザや Node 内で完結させたい場合に効きます。
2. 最大の設計判断:DB 型を誰が持つか
Kysely の型安全は、たった一点に依存しています。
import { Kysely, PostgresDialect } from "kysely";
// この interface が「DBの形」の唯一の真実源になる
interface Database {
person: PersonTable;
pet: PetTable;
}
const db = new Kysely<Database>({ dialect: new PostgresDialect({ pool }) });
Kysely はこの Database が実際のスキーマと一致しているかを検証しません。 渡された型を信じて、クエリの型を組み立てるだけです。つまり型の正しさは、あなたがこの interface を実スキーマと同期し続けられるかにかかっています。
これが Kysely 最大の設計判断であり、最大の事故要因です。選択肢は実務上3つで、それぞれ壊れ方が違います。
| 方式 | 真実源 | 壊れ方 |
|---|---|---|
| 手書き | 人間の記憶 | DB を変えて interface を直し忘れる。型は通り、実行時に落ちる |
| kysely-codegen(コミュニティ製) | 実データベース | 生成を忘れると古い型のまま。DB 接続が生成の前提になる |
| prisma-kysely | schema.prisma | Prisma を型定義のためだけに抱える。二重管理になりうる |
どれを選んでも共通の対策はひとつです——再生成を CI に組み込み、差分が出たら失敗させる。生成物をコミットし、CI で再生成して git diff --exit-code する形が最も単純で確実です。
重要:
kysely-codegenは公式パッケージではなくコミュニティ製です。「Kysely が型を自動生成してくれる」という説明を見かけますが、Kysely 本体に型生成機能はありません。ここを誤解したまま採用すると、運用設計が丸ごと抜けます。
Generated / ColumnType で「読み書きの非対称」を表す
DB の列は、読むときと書くときで型が違います。id は SELECT では必ず存在しますが、INSERT では省略できます。Kysely はこれを型で表現します。
import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely";
interface PersonTable {
id: Generated<number>; // INSERT時は省略可、SELECT時は必ずある
first_name: string;
// <SELECT型, INSERT型, UPDATE型>
created_at: ColumnType<Date, string | undefined, never>; // 更新は禁止
}
type Person = Selectable<PersonTable>; // 読み出し
type NewPerson = Insertable<PersonTable>; // 挿入
type PersonUpdate = Updateable<PersonTable>; // 更新
created_at の3番目を never にしてあるのがポイントです。「作成日時は更新できない」というビジネスルールを、レビューではなくコンパイラに守らせています。 ORM のフックで実現しがちな不変条件を、型で表現できるのが Kysely の気持ちよさです。
3. Migrator は throw しない(本番の落とし穴)
ここが Kysely 運用で最も事故りやすい箇所です。公式ソースの契約を見ます。
// src/migration/migrator.ts の docblock より
const { error, results } = await migrator.migrateToLatest();
results?.forEach((it) => {
if (it.status === "Success") { /* ... */ }
else if (it.status === "Error") { /* ... */ }
});
if (error) {
console.error("failed to run `migrateToLatest`");
console.error(error);
}
migrateToLatest() は Promise<MigrationResultSet> を返します。失敗しても例外を投げません。 つまり、こう書いたデプロイスクリプトは壊れています。
// ❌ 壊れている:マイグレーション失敗が成功として通る
await migrator.migrateToLatest();
console.log("migrated");
process.exit(0);
await は成功します。error に失敗が入っているだけです。CI は緑になり、スキーマが古いままアプリがデプロイされます。正しくはこうです。
// ✅ 失敗を必ずプロセスの終了コードに変換する
const { error, results } = await migrator.migrateToLatest();
for (const it of results ?? []) {
const line = `${it.status.padEnd(7)} ${it.migrationName}`;
it.status === "Error" ? console.error(line) : console.log(line);
}
await db.destroy();
if (error) {
console.error("migration failed:", error);
process.exit(1); // ← これが無いとCIが失敗を握り潰す
}
例外を投げない設計自体は妥当です(部分適用された結果を呼び出し側に渡せる)。問題は、素直に書くと失敗を見落とす形になっていることで、だからこそこのラッパーを最初に書いておく価値があります。
履歴とロック、そして順序
ソース上の定数から、運用に必要な事実が読み取れます。
export const DEFAULT_MIGRATION_TABLE = "kysely_migration";
export const DEFAULT_MIGRATION_LOCK_TABLE = "kysely_migration_lock";
export const DEFAULT_ALLOW_UNORDERED_MIGRATIONS = false;
export const NO_MIGRATIONS: NoMigrations = freeze({ __noMigrations__: true });
- 履歴は
kysely_migration、排他はkysely_migration_lockに置かれます。複数インスタンスが同時にデプロイされてもロックで直列化されますが、そのぶんロックテーブルはバックアップ・リストアの対象に含める必要があります。 allowUnorderedMigrationsの既定はfalse。 複数人が並行してブランチを切り、それぞれマイグレーションを追加した場合、名前順が前後すると既定では失敗します。チーム開発では意図的にtrueにするか、マージ時に採番を直す運用を決めておきます。migrateTo(NO_MIGRATIONS)で全部巻き戻せます。downを書いていない移行があると途中で止まるため、「ロールバックできる」と言い切りたいならdownは必須です。
4. SQL の三値論理を、型では守れない場所
Kysely は SQL を隠しません。したがって SQL の意味論的な落とし穴はそのまま残ります。TypeScript の型はここを守ってくれません。公式プラグインは、まさにこの2つを塞ぐために存在します。
4.1 in () は構文エラーになる
配列を in に渡すコードは自然に書けます。
const ids: number[] = await getSelectedIds(); // ← 空配列になりうる
const rows = await db.selectFrom("person").selectAll()
.where("id", "in", ids)
.execute();
ids が空だと、生成される SQL は where id in () です。これは多くの RDBMS で構文エラーです。型は通ります。テストデータが常に非空なら、本番で初めて落ちます。
HandleEmptyInListsPlugin はこれを変換します。ソースにある戦略のひとつ replaceWithNoncontingentExpression は、in () を恒偽式(1 = 0)に、not in () を恒真式に置き換えます。docblock はこの戦略を "similarily to how Knex.js, PrismaORM, Laravel, SQLAlchemy handle this"(原文ママ。similarily の綴りも含む)と説明し、それぞれの実装箇所へリンクしています。つまり Kysely 独自の裏技ではありません。
ただし同じ docblock は、この手の書き換えについて "The workarounds used by other libraries always involve modifying the query under the hood, which is not aligned with Kysely's philosophy of WYSIWYG" とも書いており、あくまで opt-in である理由を明言しています。「他もやっているから安全」ではなく、**「他もやっているが、Kysely は既定にはしない」**が正確な読み方です。空配列は渡す前に自分で弾くのが第一選択で、このプラグインは次善策です。
import { Kysely, HandleEmptyInListsPlugin, replaceWithNoncontingentExpression } from "kysely";
const db = new Kysely<Database>({
dialect,
plugins: [new HandleEmptyInListsPlugin({ strategy: replaceWithNoncontingentExpression })],
});
もうひとつの戦略 pushValueIntoList は、リストにダミー値を押し込んでインデックスを効かせ続ける方針です。恒偽式に置き換えるとオプティマイザの挙動が変わるため、大きなテーブルでは実行計画を確認して選びます。
4.2 = NULL は FALSE ではなく NULL
SafeNullComparisonPlugin の docblock が、理由まで明記しています。
In SQL, comparing values with NULL using standard comparison operators (=, !=, <>) always yields NULL, which is usually not what developers expect.
SQL は三値論理(TRUE / FALSE / UNKNOWN)で動きます。x = NULL は「偽」ではなく「不明」に評価され、WHERE 句は不明を通しません。だから該当行があっても 0 件が返ります。エラーは出ません。
// nullable な検索条件をそのまま渡すと、null のとき必ず 0 件になる
const name: string | null = req.query.name ?? null;
await db.selectFrom("person").selectAll().where("first_name", "=", name).execute();
プラグインは値が null のとき演算子を差し替えます(ソースの docblock より)。
=→IS!=→IS NOT<>→IS NOT
import { SafeNullComparisonPlugin } from "kysely";
const db = new Kysely<Database>({ dialect, plugins: [new SafeNullComparisonPlugin()] });
この2つは「Kysely のバグを回避するプラグイン」ではありません。SQL の仕様が持つ鋭利さを、アプリ側で毎回書く代わりに一箇所で吸収する装置です。ORM はこれらを内側に隠しているので気づきません。SQL を露出させる道具を選ぶということは、この責任も引き取るということです。
5. 本番構成:型・トランザクション・接続
トランザクションはコールバックで閉じる
await db.transaction().execute(async (trx) => {
const person = await trx.insertInto("person")
.values({ first_name: "Jennifer" })
.returningAll()
.executeTakeFirstOrThrow();
await trx.insertInto("pet").values({ owner_id: person.id, name: "Catto" }).execute();
});
trx は Kysely<Database> と同じインターフェースを持つため、リポジトリ関数の引数型を Kysely<DB> | Transaction<DB> にしておけば、トランザクション内外で同じ関数を使い回せます。この一手間が、後から「この処理もトランザクションに入れたい」と言われたときのコストを消します。
type Db = Kysely<Database> | Transaction<Database>;
export async function findPersonById(db: Db, id: number) {
return db.selectFrom("person").selectAll().where("id", "=", id).executeTakeFirst();
}
executeTakeFirst と executeTakeFirstOrThrow を意識的に使い分ける
execute()→T[]executeTakeFirst()→T | undefinedexecuteTakeFirstOrThrow()→T(無ければ throw)
undefined を返す版を使ったなら、型が「無いかもしれない」と言っています。 その分岐を書かずに ! で潰すのは、Kysely を選んだ意味を捨てる行為です。逆に「無いなら異常」なら OrThrow を使い、分岐そのものを消します。
生 SQL は sql タグで、値は必ずパラメータに
import { sql } from "kysely";
// ✅ 値は自動的にプレースホルダになる
const rows = await sql<{ id: number }>`
select id from person where first_name = ${name}
`.execute(db);
// ❌ 識別子を文字列結合すると SQL インジェクション
// sql`select * from ${sql.raw(tableName)}` ← tableName が外部入力なら危険
sql タグの ${} はパラメータ化されます。一方 sql.raw() は文字列をそのまま埋め込むため、外部入力を渡してはいけません。テーブル名やソート列を動的にしたい場合は、必ず許可リストで受けてください。
const SORTABLE = { name: "first_name", created: "created_at" } as const;
const sort = String(req.query.sort ?? "");
// `Object.hasOwn` が安全性の本体。ここを `SORTABLE[sort] ?? "created_at"` と
// 書くと許可リストは素通りする——`?sort=constructor` や `?sort=toString` は
// プロトタイプ由来の値を返し、undefined ではないので `??` が発火しない。
const column = Object.hasOwn(SORTABLE, sort)
? SORTABLE[sort as keyof typeof SORTABLE]
: "created_at";
6. Kysely を選んではいけないケース
正直に書きます。次のいずれかなら、Kysely は選ぶべきではありません。
- Node 20 以下で運用している —
engines.node >= 22.0.0。ここは議論の余地がありません。 - リレーションを宣言的にたどりたい — ネストした関連データの取得は自分で JOIN と整形を書きます。ORM の
includeに相当するものはありません。 - チームが SQL を読み書きしない — 抽象を薄くする道具は、薄くした先を読める人がいて初めて価値になります。
- スキーマ定義を唯一の真実源にしたい — Kysely は型を生成しません。真実源を別に用意する運用設計が必須です。
1〜4 に当てはまらず、かつ「SQL を書けるのがむしろ利点」なら、Kysely は非常に良い道具です。**抽象が薄いぶん、驚きが少ない。**この性質は、障害対応で実行計画を追う場面で効いてきます。
7. 導入チェックリスト
本番投入前に、この7つを確認してください。
- Node のバージョンが 22 以上である(
engines.node >= 22.0.0) -
DB型の真実源を決めた(手書き / kysely-codegen / prisma-kysely) - 型の再生成が CI に組み込まれ、差分が出たら失敗する
- マイグレーション実行が
errorを見て非ゼロ終了する -
allowUnorderedMigrationsの方針をチームで合意した -
HandleEmptyInListsPlugin/SafeNullComparisonPluginの採否を判断した -
sql.raw()に外部入力が渡らないことをレビュー基準にした
このうち 4番目と6番目は、型が守ってくれない領域です。だからこそチェックリストに残す価値があります。
まとめ
Kysely は「軽い ORM」ではなく、ORM を使わないと決めたときに、型安全だけは手放さないための道具です。
- 素性は
package.jsonで確認できる:ゼロ依存・sideEffects: false・engines.node >= 22.0.0・まだ 0.x - 型は生成されない。
interface DBの真実源を決め、CI で同期を強制するのが設計の中心 Migratorは throw しない。errorを見ないデプロイスクリプトは失敗を握り潰すin ()と= NULLは SQL の仕様であり、公式プラグインで一箇所に閉じ込められる
技術選定は宗教戦争ではなく、制約の突き合わせです。4ツールの横並びは技術選定ガイドに、Prisma / Drizzle をそれぞれ深く使う実装はPrisma 本番運用ガイド・Drizzle 本番運用ガイドにまとめています。