型安全な SQL クエリビルダー「Kysely」の利点を検証する
はじめに
LINE/アプリ DevOps チームの及川です。
Claude Code をはじめとする AI コーディング支援の登場で、言語やフレームワークといった技術的な壁を越えやすくなり、以前は一人では届きにくかった領域にも踏み込みやすくなったと感じています。
私自身も、業務のバックエンドで使っている Kysely というクエリビルダーを、AI と確認・相談(いわゆる壁打ち)をしながら書いてきました。そのおかげで動くものは形にできていましたが、「動かせること」と「仕組みを理解して、自分の言葉で説明できること」は別だと感じています。実際、次のような点は、自分の中できちんと整理できていませんでした。
- そもそも Kysely とは何か。ORM なのか、SQL 直書きなのか
- 何が便利で使っているのか
- 同じプロジェクトに Prisma も入っているが、役割はどう違うのか
そこで、業務とは無関係な「図書館」を題材に、ゼロから検証プロジェクトを組み、手を動かしながら整理し直しました。この記事は、その検証をもとに Kysely の立ち位置と利点をまとめたものです。
この記事で紹介すること
- SQL 直書き / ORM / Kysely の書き比べと、Kysely の立ち位置
- 型安全の体感(存在しない列や型の誤りが、実行前にコンパイルエラーになる)
- JOIN / N+1 回避(jsonArrayFrom)/ UPSERT / トランザクションの書き方
- Prisma と Kysely の役割分担
- 実 DB に対するテスト(medium テスト)での検証結果
Kysely がどのようなものかは、公式サイトに以下のとおり記載されています。
The type-safe SQL query builder for TypeScript
Kysely's state-of-the-art, type-safe API provides precise result types and catches errors within queries at compile-time
検証はローカルの PostgreSQL(Docker)に対するテストで確認しています(medium テスト 20 件はすべて成功)。掲載する SQL は .compile() の出力、テスト結果は実行時のものです。
結論
先に本記事の結論をまとめます。
- Kysely は ORM でも SQL 直書きでもなく、SQL を隠さない「クエリビルダー」です。SQL の構造がコードにほぼそのまま表れます
- 型安全: テーブル名・列名・型の誤りを、実行前(コンパイル時)に検出できます。戻り値の型も自動で付きます
- Prisma とは役割分担です。Prisma がスキーマ定義とマイグレーション、Kysely が実行時のクエリを担います
- 複雑なクエリも型を保ったまま記述できます(JOIN・jsonArrayFrom による N+1 回避・UPSERT・トランザクション)
3 方式を整理した比較が次の表です(◯ 有利 / △ 注意・手間)。用途ごとの向き不向きを示すもので、ツールの優劣ではありません。
| 観点 | SQL 直書き (文字列) | ORM (Prisma 等) | クエリビルダー (Kysely) |
|---|---|---|---|
| 列名・テーブル名の誤り検出 | △ 実行時に判明 | ◯ | ◯ コンパイル時 |
| 戻り値の型が自動で付く | △ 自分で型定義 | ◯ | ◯ 自動推論 |
検証内容
検証環境
検証用の題材には、身近でイメージしやすい「図書館」を選びました。本(Book)、著者(Author)、会員(Member)、貸出(Loan)の 4 テーブルで、関係は次のとおりです。
Author ──< BookAuthor >── Book ──< Loan >── Member
(著者) (中間テーブル) (本) (貸出) (会員)
- Book と Author は多対多(1 冊に複数著者)→ 中間テーブル BookAuthor
- Book と Member は Loan を介した貸出関係
- Book は在庫冊数 availableCopies を持つ
この構成だけで、入門に必要な要素(JOIN、多対多、UPSERT、トランザクション)を一通り検証できます。
テーブルは Prisma で定義します。要点は generator kysely の行で、ここから Kysely 用の型が自動生成されます。
generator kysely {
provider = "prisma-kysely"
output = "./generated"
fileName = "types.ts"
enumFileName = "enums.ts"
}
model Book {
id Int @id @default(autoincrement())
title String
isbn String @unique
totalCopies Int @default(1)
availableCopies Int @default(1)
publishedAt DateTime?
createdAt DateTime @default(now())
authors BookAuthor[]
loans Loan[]
}
prisma migrate dev を実行すると、テーブルが作られると同時に prisma/generated/types.ts が生成されます。生成される型は次のような内容です。
export type Book = {
id: Generated<number>;
title: string;
isbn: string;
totalCopies: Generated<number>;
availableCopies: Generated<number>;
publishedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
};
export type DB = {
Author: Author;
Book: Book;
BookAuthor: BookAuthor;
Loan: Loan;
Member: Member;
};
この DB 型が、Kysely における型安全の起点です。Kysely クライアントを Kysely<DB> として作ることで、「どのテーブルがあり、各テーブルにどの列・型があるか」を Kysely が把握します。
export const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL }),
}),
});
Generated<T> は「INSERT 時は省略してよい(DB が自動で埋める)列」を表します(id や createdAt など)。そのため INSERT 時に id を書かなくても型エラーにならず、SELECT では number として受け取れます。
この題材では Prisma と Kysely を役割分担で併用しています。Prisma でテーブルの設計図を書き、その設計図から Kysely 用の型を自動生成し、実際のクエリは Kysely で書く、という分業です。
検証方法
各リポジトリに medium テスト(モックではなく実 DB に対するテスト)を書き、ローカルの PostgreSQL に対して実行しました。Kysely のクエリは実行して初めて結果が分かるため、この形が確認に適しています。あわせて、実際に発行される SQL は .compile() で取り出して確認しています。
なお、以降に載せるコードは、記事向けに要点を絞った抜粋例です(クライアントの生成など前後の準備は省いています)。掲載している生成 SQL は、この抜粋のコードから実際に得られる出力です。
SQL の書き比べ ― 直書き / ORM / Kysely
まず、Kysely の立ち位置を見るために、「在庫がある本(availableCopies > 0)を、タイトル順に一覧する」という同じ処理を 3 つの方法で書いてみます。
SQL 直書きの場合です(pg ドライバを使用)。
const result = await pool.query(
'select "id", "title", "isbn" from "Book" where "availableCopies" > $1 order by "title"',
[0],
);
const books = result.rows; // 戻り値の型は any[]。誤りは実行するまで分からない
ORM(Prisma)の場合です。SQL は書かず、オブジェクトの操作として記述します。
const books = await prisma.book.findMany({
where: { availableCopies: { gt: 0 } },
select: { id: true, title: true, isbn: true },
orderBy: { title: "asc" },
});
クエリビルダー(Kysely)の場合です。
const books = await db
.selectFrom("Book")
.select(["id", "title", "isbn"])
.where("availableCopies", ">", 0)
.orderBy("title")
.execute();
select ... from "Book" where ... order by ... という SQL の構造が、ほぼそのままメソッドチェーンになっています。Kysely は SQL を隠さない設計で、書いたコードから発行される SQL を予測できます。3 つを並べると、Kysely は「SQL 直書きに近い書き味」と「ORM のような型の恩恵(戻り値に型が付く)」の中間に位置するものと考えています。それぞれの書き味を、身近なものにたとえると次のようになります。
- SQL 直書き: 手書きの文章。自由に書けますが、列名の誤りに気付けるのは実行後です
- ORM: 定型フォーマットの申請書。決まった型に沿って手軽に書けます
- クエリビルダー(Kysely): 入力補完つきのエディタで書く文章。自由に書けて、誤りはその場で指摘されます
基礎編 ― SELECT / WHERE / ORDER BY と型安全の体感
条件が指定されたときだけ .where() を足す、動的なクエリ組み立ての例です。
async search(params: { titleContains?: string; onlyAvailable?: boolean } = {}) {
let query = db
.selectFrom("Book")
.select(["id", "title", "isbn", "availableCopies"]);
if (params.titleContains !== undefined) {
query = query.where("title", "ilike", `%${params.titleContains}%`);
}
if (params.onlyAvailable === true) {
query = query.where("availableCopies", ">", 0);
}
return query.orderBy("title").execute();
}
このとき execute() の戻り値は、型注釈を書いていないのに { id: number; title: string; isbn: string; availableCopies: number }[] と推論されます。select([...]) に並べた列から、戻り値の型が自動的に決まるためです。
型安全を体感できるのが次の挙動です。存在しない列を書くと、実行する前にコンパイルエラーになります。
db.selectFrom("Book").select(["titel"]); // タイプミス → コンパイルエラー
db.selectFrom("Book").where("price", ">", 0); // Book に price 列はない → コンパイルエラー
SQL 直書きの場合、こうした誤りは実行して初めて column "titel" does not exist として判明します。Kysely では実行前に検出できます。この「実行前に検出できる」点が、本検証で確認できた主な利点だと考えています。
JOIN 編 ― テーブルをつなぐ
貸出中の一覧を、本のタイトルと会員名つきで出したいとします。Loan に Book と Member を内部結合します。
const rows = await db
.selectFrom("Loan")
.innerJoin("Book", "Book.id", "Loan.bookId")
.innerJoin("Member", "Member.id", "Loan.memberId")
.select([
"Loan.id as loanId",
"Book.title as bookTitle",
"Member.name as memberName",
])
.where("Loan.returnedAt", "is", null) // 返却されていない = 貸出中
.execute();
innerJoin("Book", "Book.id", "Loan.bookId") は inner join "Book" on "Book"."id" = "Loan"."bookId" に対応します。SQL とほぼ同じ構造です。JOIN したあとも型は有効で、"Book.titel" のような誤りはコンパイルエラーになり、結合していないテーブルの列は補完候補に出にくくなります。
N+1 回避編 ― jsonArrayFrom
本の一覧を、それぞれの著者一覧つきで取得したいとします。素朴に実装すると「本を全部取得(1 クエリ)」+「本ごとに著者を取得(N クエリ)」で合計 N+1 クエリになります。これがいわゆる N+1 問題です。
Kysely の jsonArrayFrom を使うと、関連を「1 クエリの中の JSON 配列」として同梱できます。
import { jsonArrayFrom } from "kysely/helpers/postgres";
const rows = await db
.selectFrom("Book")
.select((eb) => [
"Book.id",
"Book.title",
jsonArrayFrom(
eb
.selectFrom("BookAuthor")
.innerJoin("Author", "Author.id", "BookAuthor.authorId")
.select(["Author.id", "Author.name"])
.whereRef("BookAuthor.bookId", "=", "Book.id")
.orderBy("Author.id"),
).as("authors"),
])
.orderBy("Book.id")
.execute();
戻り値は { id: number; title: string; authors: { id: number; name: string }[] }[] と推論され、authors が配列として型付けされます。実際に発行される SQL を .compile() で確認すると、1 本の SQL になっています。
select "Book"."id", "Book"."title",
(select coalesce(json_agg(agg), '[]')
from (select "Author"."id", "Author"."name"
from "BookAuthor"
inner join "Author" on "Author"."id" = "BookAuthor"."authorId"
where "BookAuthor"."bookId" = "Book"."id"
order by "Author"."id") as agg) as "authors"
from "Book" order by "Book"."id"
json_agg で著者を配列にまとめ、本のクエリにサブクエリとして埋め込んでいます。発行されるのは N+1 ではなく 1 クエリです。これを型安全に書ける点は、Kysely と PostgreSQL を組み合わせる利点だと考えています。
書き込み編 ― INSERT / UPSERT
複数 INSERT は onConflict(...).doNothing() で重複を無視できます。
await db
.insertInto("Member")
.values([
{ name: "会員1", email: "m1@example.com" },
{ name: "会員2", email: "m2@example.com" },
])
.onConflict((oc) => oc.column("email").doNothing()) // email 重複は無視
.execute();
UPSERT(無ければ INSERT、有れば UPDATE)は doUpdateSet(...) です。excluded は「INSERT しようとした新しい行」を指す PostgreSQL の特別な参照です。
await db
.insertInto("Member")
.values({ name: "山田太郎", email: "yamada@example.com" })
.onConflict((oc) =>
oc.column("email").doUpdateSet({
name: (eb) => eb.ref("excluded.name"),
}),
)
.execute();
発行される SQL は次のとおりです。値が $1, $2 に分離される点にも注目してください。Kysely はビルダー経由で渡した値を基本的にプレースホルダへ分離するため、SQL インジェクション対策が標準で効きます。
insert into "Member" ("name", "email") values ($1, $2)
on conflict ("email") do update set "name" = "excluded"."name"
トランザクション編 ― 貸出処理をひとまとまりにする
図書館の「貸出」は「在庫を 1 減らす」と「貸出(Loan)を作る」を必ずセットで行う必要があります。片方だけ成功すると在庫がずれるため、トランザクションの出番です。
async borrow(params: { bookId: number; memberId: number; dueAt: Date }) {
return db.transaction().execute(async (trx) => {
// 在庫がある行だけ -1 する。在庫切れなら 1 行も更新されない。
const decremented = await trx
.updateTable("Book")
.set((eb) => ({ availableCopies: eb("availableCopies", "-", 1) }))
.where("id", "=", params.bookId)
.where("availableCopies", ">", 0)
.returning("id")
.executeTakeFirst();
if (decremented === undefined) {
throw new OutOfStockError(params.bookId); // 在庫切れ
}
// 在庫が引けたときだけ貸出を作る
return trx
.insertInto("Loan")
.values({ bookId: params.bookId, memberId: params.memberId, dueAt: params.dueAt })
.returningAll()
.executeTakeFirstOrThrow();
});
}
db.transaction().execute(async (trx) => { ... }) の中で trx を使うのが要点です。コールバックが正常終了すれば COMMIT、途中で例外を投げれば自動で ROLLBACK されます。工夫は where("availableCopies", ">", 0) です。在庫が 0 なら UPDATE は 1 行も更新せず executeTakeFirst() が undefined を返すので、そこで例外を投げるとトランザクションごと巻き戻り、Loan も作られません。「在庫を確認してから貸す」を 1 つの UPDATE で原子的に行っています。
まとめ
最初に挙げた疑問に、現時点での答えを整理します。
| 当時の疑問 | 整理した答え |
|---|---|
| Kysely は ORM か、SQL 直書きか | どちらでもなく、SQL を隠さないクエリビルダーです |
| 何が便利か | 列名・型の誤りを実行前に検出でき、戻り値に型が付きます |
| Prisma との違いは | Prisma は設計図とマイグレーション、Kysely は実行時のクエリを担います |
| 複雑なクエリは書けるか | JOIN・jsonArrayFrom・UPSERT を型安全に記述できます |
| N+1 はどうするか | jsonArrayFrom で関連を 1 クエリに同梱できます |
この記事が誰かのお役に立てば幸いです。
参考情報
クラスメソッドオペレーションズ株式会社について
クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AI をフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。※2026 年 1 月 アノテーション㈱から社名変更しました





