メインコンテンツへスキップ
データベース・RLS
TypeScript
型安全
データベース
PostgreSQL
アーキテクチャ設計
技術選定

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

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

公開日
読了時間
14分
著者
友田 陽大
シェア

「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 に戻ることになります。


この記事の実装を、案件として承ります

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 ドライバ(pgmysql2 等)は利用側が直接持つ形になります。

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-kyselyschema.prismaPrisma を型定義のためだけに抱える。二重管理になりうる

どれを選んでも共通の対策はひとつです——再生成を 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();
});

trxKysely<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();
}

executeTakeFirstexecuteTakeFirstOrThrow を意識的に使い分ける

  • execute()T[]
  • executeTakeFirst()T | undefined
  • executeTakeFirstOrThrow()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 は選ぶべきではありません。

  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: falseengines.node >= 22.0.0まだ 0.x
  • 型は生成されない。 interface DB の真実源を決め、CI で同期を強制するのが設計の中心
  • Migrator は throw しない。 error を見ないデプロイスクリプトは失敗を握り潰す
  • in ()= NULLSQL の仕様であり、公式プラグインで一箇所に閉じ込められる

技術選定は宗教戦争ではなく、制約の突き合わせです。4ツールの横並びは技術選定ガイドに、Prisma / Drizzle をそれぞれ深く使う実装はPrisma 本番運用ガイドDrizzle 本番運用ガイドにまとめています。

よくある質問

Kysely は ORM ですか? Prisma や Drizzle の代わりになりますか?
Kysely は ORM ではなく、型安全な SQL クエリビルダです。エンティティのライフサイクル管理もリレーションの自動解決もマイグレーション DSL からのスキーマ生成も行いません。したがって「Prisma の代わり」ではなく「ORM を使わないという選択」です。モデル中心に開発したい、リレーションを宣言的にたどりたい、スキーマを唯一の真実源にしたい——このいずれかが要件なら ORM を選ぶべきです。逆に SQL を書くこと自体が要件(複雑な集計、ウィンドウ関数、DB 固有機能)で、抽象は型だけで十分なら Kysely が噛み合います。
Kysely はデータベースの型を自動生成してくれますか?
しません。Kysely は `Kysely<DB>` の `DB` として渡された TypeScript の interface を信じて型を組み立てるだけで、実際のスキーマとの一致は検証しません。型の真実源をどこに置くかは利用者が決める設計判断で、手書き、kysely-codegen(コミュニティ製・DB から生成)、prisma-kysely(Prisma スキーマから生成)の3択が実務的な選択肢です。どれを選んでも「DB を変えたのに型を再生成し忘れる」と型は通るのに実行時に壊れるため、再生成を CI に組み込むことが前提になります。
Kysely のマイグレーションは本番で使えますか?
使えますが、エラー処理の契約に注意が必要です。`Migrator` は `migrateToLatest()` が失敗しても例外を投げず、`{ error, results }` を返します。`await migrator.migrateToLatest()` と書くだけのデプロイスクリプトは、マイグレーション失敗を成功として通してしまいます。必ず `error` を判定して非ゼロ終了してください。ロックは `kysely_migration_lock` テーブル(既定名)で取り、履歴は `kysely_migration` に記録されます。`allowUnorderedMigrations` の既定値は false です。
`where('id', '=', value)` で value が null のとき、なぜ結果が返らないのですか?
SQL の三値論理により `x = NULL` は TRUE でも FALSE でもなく NULL に評価され、WHERE 句は NULL を偽として扱うためです。これは Kysely のバグではなく SQL の仕様で、正しくは `IS NULL` を使います。TypeScript 側で `string | null` を扱うと条件分岐が散らかるため、Kysely は `SafeNullComparisonPlugin` を提供しており、値が null のときに `=` を `IS` へ、`!=` / `<>` を `IS NOT` へ自動変換します。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

ORM選定・データモデル設計・ゼロダウンタイム移行を、設計から実装まで承ります

「どのORMを選ぶか」より「そのデータモデルが5年後の変更に耐えるか」が本質です。Prisma / Drizzle / SQLAlchemy などの選定、正規化と非正規化の線引き、N+1 と接続プールの設計、そして稼働中のサービスを止めないスキーマ移行までを一貫して設計・実装します。決済プラットフォームで信頼性レイヤーを主導し、冪等性と整合性を設計して本番の二重課金ゼロを維持した経験から、壊れたときに気づける・戻せるデータ層をつくります。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい