# Kysely 本番運用ガイド：型安全SQLクエリビルダを「ORMを使わない」選択肢として設計する（2026）

> KyselyはORMではなく型安全なSQLクエリビルダです。ゼロ依存・Node 22以上という素性、DB型の与え方（手書き vs codegen）、Migratorがthrowしない落とし穴、in ()やNULL比較というSQLの三値論理をプラグインで塞ぐ設計まで、公式ソースで裏を取った事実だけで本番運用の判断軸を示します。

- 公開日: 2026-09-05
- 著者: 友田 陽大
- タグ: TypeScript, 型安全, データベース, PostgreSQL, アーキテクチャ設計, 技術選定
- URL: https://tomodahinata.com/blog/kysely-typescript-type-safe-sql-query-builder-production-guide
- カテゴリ: データベース・RLS
- 総合ガイド: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- Kyselyは「ORMを薄くしたもの」ではない。リレーション解決もスキーマ管理もせず、SQLの形をそのままTypeScriptの型で守る。ORMが要らない案件でのみ正解になる
- 素性はpackage.jsonで確認できる：dependencies は空（ゼロ依存）、sideEffects: false、engines.node は >=22.0.0。Node 20 LTS 環境では 0.29.5 は選定候補から外れる
- DBの型はKyselyが生成しない。`interface DB` を誰が真実源にするかを最初に決める設計判断で、手書き・kysely-codegen・prisma-kyselyの3択にはそれぞれ壊れ方がある
- Migrator は失敗しても throw しない。`const { error, results } = await migrator.migrateToLatest()` の error を見ないCIは、マイグレーション失敗を成功として通す
- `in ()` の構文エラーと `= NULL` の三値論理は公式プラグイン（HandleEmptyInLists / SafeNullComparison）で塞げる。どちらもSQLの仕様であってKyselyのバグではない

---

「TypeScript で DB 層を作る」と言うと、まず ORM の名前が挙がります。しかし案件によっては、**ORM が提供する抽象そのものが要らない**ことがあります。集計とウィンドウ関数が主役の分析系、DB 固有機能に踏み込む必要がある基盤、あるいは「SQL は読めるチームなので、型だけ守ってくれればいい」という現場です。

その領域に**Kysely**があります。よく「軽量な ORM」と紹介されますが、それは正確ではありません。Kysely は **ORM ではなく、型安全な SQL クエリビルダ**です。この違いは好みの問題ではなく、**何を自分で持ち続ける責任があるか**の違いです。

この記事は、Kysely を本番で使うかどうかを判断し、使うなら壊れにくく組むための設計ガイドです。4ツールの横並び比較は[Prisma vs Drizzle vs TypeORM vs Kysely 技術選定ガイド](/blog/prisma-vs-drizzle-vs-typeorm-kysely-orm-comparison-guide)に譲り、ここでは **Kysely 単体を本番運用する視点**に絞ります。

> **この記事のルール**：バージョン・依存関係・API の挙動は、すべて **GitHub の実ソース（`master`、v0.29.5 時点）** で確認した事実のみを書きます。要約サイトや二次情報の数値は使いません。最新は必ず[公式](https://kysely.dev/docs/intro)で確認してください。

---

## 0. 結論を先に：Kysely が正解になる条件

3つすべてに当てはまるなら Kysely が噛み合います。ひとつでも外れるなら ORM を選んだほうが幸せです。

| 条件 | 当てはまる | 当てはまらない |
|---|---|---|
| **SQL を書くこと自体が要件** | 複雑な集計・ウィンドウ関数・DB固有機能を使う | CRUD が大半で、SQL を書きたくない |
| **リレーションの自動解決が不要** | JOIN は自分で書くほうが速い・読める | ネストした関連データを宣言的に取りたい |
| **型の真実源を自分で管理できる** | codegen を CI に組み込む運用ができる | スキーマ定義から全部を導出したい |

Kysely は「ORM を薄くしたもの」ではなく、**ORM を使わないという選択の受け皿**です。ここを取り違えると、後から「リレーションが面倒」「スキーマ管理が手薄」と言って ORM に戻ることになります。

---

## 1. 素性を `package.json` で確認する

技術選定でいちばん誤情報が多いのがここです。実際のマニフェストを見ます。

```jsonc
// 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 の型安全は、たった一点に依存しています。

```ts
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 はこれを型で表現します。

```ts
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 運用で最も事故りやすい箇所です。公式ソースの契約を見ます。

```ts
// 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>` を返します。**失敗しても例外を投げません。** つまり、こう書いたデプロイスクリプトは壊れています。

```ts
// ❌ 壊れている：マイグレーション失敗が成功として通る
await migrator.migrateToLatest();
console.log("migrated");
process.exit(0);
```

`await` は成功します。`error` に失敗が入っているだけです。CI は緑になり、スキーマが古いままアプリがデプロイされます。正しくはこうです。

```ts
// ✅ 失敗を必ずプロセスの終了コードに変換する
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が失敗を握り潰す
}
```

例外を投げない設計自体は妥当です（部分適用された結果を呼び出し側に渡せる）。**問題は、素直に書くと失敗を見落とす形になっていること**で、だからこそこのラッパーを最初に書いておく価値があります。

### 履歴とロック、そして順序

ソース上の定数から、運用に必要な事実が読み取れます。

```ts
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` に渡すコードは自然に書けます。

```ts
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 は既定にはしない」**が正確な読み方です。空配列は渡す前に自分で弾くのが第一選択で、このプラグインは次善策です。

```ts
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 件が返ります**。エラーは出ません。

```ts
// 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`

```ts
import { SafeNullComparisonPlugin } from "kysely";
const db = new Kysely<Database>({ dialect, plugins: [new SafeNullComparisonPlugin()] });
```

**この2つは「Kysely のバグを回避するプラグイン」ではありません。SQL の仕様が持つ鋭利さを、アプリ側で毎回書く代わりに一箇所で吸収する装置**です。ORM はこれらを内側に隠しているので気づきません。SQL を露出させる道具を選ぶということは、この責任も引き取るということです。

---

## 5. 本番構成：型・トランザクション・接続

### トランザクションはコールバックで閉じる

```ts
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>` にしておけば、トランザクション内外で同じ関数を使い回せます**。この一手間が、後から「この処理もトランザクションに入れたい」と言われたときのコストを消します。

```ts
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 | undefined`
- `executeTakeFirstOrThrow()` → `T`（無ければ throw）

**`undefined` を返す版を使ったなら、型が「無いかもしれない」と言っています。** その分岐を書かずに `!` で潰すのは、Kysely を選んだ意味を捨てる行為です。逆に「無いなら異常」なら `OrThrow` を使い、分岐そのものを消します。

### 生 SQL は `sql` タグで、値は必ずパラメータに

```ts
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()` は文字列をそのまま埋め込むため、**外部入力を渡してはいけません**。テーブル名やソート列を動的にしたい場合は、必ず許可リストで受けてください。

```ts
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 は選ぶべきではありません。**

1. **Node 20 以下で運用している** — `engines.node >= 22.0.0`。ここは議論の余地がありません。
2. **リレーションを宣言的にたどりたい** — ネストした関連データの取得は自分で JOIN と整形を書きます。ORM の `include` に相当するものはありません。
3. **チームが SQL を読み書きしない** — 抽象を薄くする道具は、薄くした先を読める人がいて初めて価値になります。
4. **スキーマ定義を唯一の真実源にしたい** — 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ツールの横並びは[技術選定ガイド](/blog/prisma-vs-drizzle-vs-typeorm-kysely-orm-comparison-guide)に、Prisma / Drizzle をそれぞれ深く使う実装は[Prisma 本番運用ガイド](/blog/prisma-orm-production-guide-type-safe-database-v7-driver-adapters)・[Drizzle 本番運用ガイド](/blog/drizzle-orm-typescript-type-safe-database-production-guide)にまとめています。
