gqlkit - TypeScript の型定義と関数から GraphQL Schema を構築するライブラリを作った
TypeScript で GraphQL Schema をいい感じに構築できるやつを作ってみたので自慢させてください。
何を作ったか(簡単に)
以下のように「TypeScript による型定義」と「define○○ をかぶせた resolver 実装関数」 を export すると、
import type { IDString, NoArgs } from "@gqlkit-ts/runtime";
import { defineQuery, defineMutation } from "../gqlkit.js";
export type Task = {
id: IDString;
title: string;
completed: boolean;
};
export const tasks = defineQuery<NoArgs, Task[]>(
async (_root, _args, ctx) => {
// db からタスクを取り出す関数
const tasks = await fetchTasks(ctx.db);
return tasks;
},
);
export const createTask = defineMutation<{ input: { title: string } }, Task>(
async (_root, { input }, ctx) => {
// db にタスクを保存する関数
const task = await saveTask(ctx.db, { title: input.title });
return task;
},
);
こんな感じで GraphQL Schema とその実装が生成されます。
type Task {
id: ID!
title: String!
completed: Boolean!
}
input CreateTaskInput {
title: String!
}
type Query {
tasks: [Task!]!
}
type Mutation {
createTask($input: CreateTaskInput!): Task!
}
従来の GraphQL Schema の課題
そもそも: なぜ GraphQL か
なぜ GraphQL か
スキーマ駆動の API 開発であれば Connect RPC や Hono OpenAPI などモダンな OpenAPI サーバ実装が、Full-stack TypeScript 前提であれば tRPC, Hono RPC, あるいは Next.js Server Components/Actions など実装的にはもっとお手軽な方法はいくらでも存在します。 実際、プロダクト開発初期において GraphQL を採用するのは too much であることもあるでしょう(こういう領域またぐデカい技術導入はちゃんと考えようという話でしかない)。
GraphQL はフロントエンドのための技術であると勘違いされがちですが、それだけではありません。 バックエンドのアーキテクチャ上も重要な意味を持ちます。 GraphQL は QL (Query Language) について議論されがちですが、 Graph であるというのが非常に重要です。 GraphQL の公式ドキュメントに以下のような記述があるように、ビジネスドメインを自然に近いかたちでグラフで表現し、 API (interface) として提供できる… というのが Graph(QL) の大きな価値です。 また、この API の利用者は Frontend に限らないでしょう(後述)。
With GraphQL, you model your business domain as a graph
また、バックエンドの実装としても通常の REST API などと比べてややこしいと思われがちですが、メンタルモデルが異なるだけで基本はそこまで複雑ではありません。 「あるオブジェクトのリスト(e.g. posts)とその関連(e.g. author)を取得したい」という例で、author を GET /posts が返す Post に含めるか、あるいは GET /users?id=... で別で batch get するか… これは正解のない問題であり、都度考えていくのはまあまあ面倒です。 一方 GraphQL では「Post に author というフィールドを実装しておいて、あとは利用側に任せる」となります。ある種オブジェクト指向的とも言える一貫した考え方でグラフを伸ばしていくことができます。
AI 時代の GraphQL
ポジショントークです。
AI coding あるいはプロダクトの AI 機能開発でも GraphQL は有効に使えると筆者は考えています。 例えば AI Agent が tool を介して様々なプロダクト内リソースにアクセスするとき, 例えば AI Agent が Web UI や Slack app, メールなどの実装でプロダクト内リソースの情報の表示を実装する時など、「ドメインがよく表現されており、かつ ViewModel logic も実装されたリソースアクセスレイヤ」として GraphQL があると便利ではないでしょうか。 縦横無尽に直接データアクセスされるわけでもなく、一貫した方法でリソースを取得できるようになっていることで、実装時の AI Agent の認知コストも下がるはずです(そうなるといいな)。
既存の GraphQL Schema Builder の課題
GraphQL Schema Builder については Code first と Schema first の2つに分けられます。
-
Code first
- 独自 DSL っぽい builder API
- e.g. Pothos GraphQL, GraphQL Nexus
- class + decorator
- e.g. NestJS (code first), TypeGraphQL
- graphql-ruby (Ruby) もこれに近い
- 独自 DSL っぽい builder API
-
Schema first
- GraphQL SDL でスキーマを記述し、そこから type 生成 + resolver 実装 scaffold
- e.g. graphql-codegen Server Preset, NestJS (schema first), gqlgen(Go)
- GraphQL SDL でスキーマを記述し、そこから type 生成 + resolver 実装 scaffold
最近だと Server Preset は GraphQL の公式ドキュメントでも取り上げられています。
Server Preset は GraphQL SDL(Schema Definition Language) で記述したスキーマから関数のシグネチャを生成してくれ、開発者はその関数を実装することになります。
extend type Query { user(id: ID!): User }import type { QueryResolvers } from './../../../types.generated' export const user: NonNullable<QueryResolvers['user']> = async (_parent, _arg, _ctx) => { /* Implement Query.user resolver logic here */ }https://the-guild.dev/graphql/codegen/docs/guides/graphql-server-apollo-yoga-with-server-preset
生成される関数は非常にシンプルで実装に迷うことはないでしょう。 しかし、スキーマ定義 → 実装 が1回で済めばいいですが、そうはいかないケースも多いでしょう。 後から resolver 分けたくなったとか、 ORM やドメインオブジェクト定義と GraphQL 上のオブジェクトの mapper を定義し忘れたとか…。 そうなると、スキーマ定義 → 実装 の後に試行錯誤のループが必要になるでしょう。 この後段の実装まで見越したスキーマ定義やその手前の mapper 設定などを、あらゆる開発者や AI Coding Agent に徹底して実行させるのは不確実性が高そうです。
一方で Code First はどうでしょうか。 例えば Pothos GraphQL は Code First かつ型安全性の高いライブラリですが、これは TypeScript でスキーマを記述できる DSL のようなものを提供し、その内部で型をうまく使うことにより resolver の引数や返り値の型を制約してくれます。
builder.queryField('giraffe', (t) => // giraffe というクエリを定義 t.field({ type: Giraffe, // giraffe は `Giraffe` を返す resolve: () => ({ // 上の `type` により resolve の返り値が `Giraffe` に制約される name: 'James', }), }), );https://pothos-graphql.dev/docs/guide/queries-mutations-and-subscriptions
これはコード生成を挟まないため開発フローとしてはスムーズですが、 DSL は独特・複雑になり、 SDL + 単純な resolve 関数に比べて可読性に勝るとは言いづらいでしょう。 また、型レベルで頑張った代償として、 resolve の返り値が制約を満たしていないケースなどで複雑な型エラーが出ることがあります。 これまた AI Coding Agent があったとしても、直感的でないエラーをうまく処理してくれるとは限りません。
gqlkit とは何か、何を解決するか
gqlkit は前述の分類では Code First になります。 一方で Pothos のような複雑な DSL はほぼ不要で、その代わりに TypeScript の型システム上の情報から GraphQL Schema を構築します。
改めて簡略化したコード例を以下に示します。
export type Task = {
id: IDString;
title: string;
completed: boolean;
};
export const tasks = defineQuery<NoArgs, Task[]>(
async (_root, _args, ctx) => {
// ...
},
);
特別な DSL は最小限で、通常の TypeScript の型定義と関数の実装を記述しているだけです。 defineQuery<...>(...) は見慣れないですが、resolver 関数を包んで型安全にしてくれるラッパーでしかありません。 この実装から冒頭で紹介したように GraphQL Schema(とそれを実行可能にするためのオブジェクト群)が生成されます。
開発体験としては「普通の関数と同じように引数・返り値の型を定義し、処理本体を実装する」という非常に簡単なものになったのではないでしょうか。 また、ここからのスキーマや resolver 設計の試行錯誤も同様に TypeScript 上で記述できます。 例えばこの Task にアサインされたユーザを取得する機能を実装するには以下のように defineField を使います。
export const assignee = defineField<Task, NoArgs, User | null>(
async (task, _args, ctx) => {
// task.assigneeId から user をいい感じに取得するコードを書く
},
);
その後コード生成をすれば、GraphQL スキーマ上に Task に assignee: User が定義されます。
このように、一連のスキーマ定義・resolver 実装のサイクルの途中にコード生成は関与せず、一貫して TypeScript 上で単純な型定義と関数実装を重ねていくだけで、最終的に実行可能な GraphQL Schema を得ることができます。 また、型はシンプルなので複雑なエラーとも遭遇しづらいでしょう。
gqlkit でできること
ここからはいくつか機能を紹介します。 途中でも出てきますが、ドキュメントサイトに llms.txt, llms-full.txt を用意しているので是非ご活用ください。
TypeScript の型から GraphQL の型 (Object, InputObject, Enum, Union, Interface) を生成
まずは単純に、TypeScript の型から GraphQL 上の型を生成できます。 Object だけでなく Enum や Union, Input Object や @oneof なども扱うことができます。
export type Task = {
id: IDString;
title: string;
status: TaskStatus;
}
// string union あるいは enum が GraphQL の Enum になる
export type TaskStatus = "todo" | "in_progress" | "done";
type Task {
id: ID!
title: String!
status: TaskStatus!
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
上記では TaskStatus を独立して定義していますが、object や enum がフィールドの型定義にインラインで書かれていても空気を読んで GraphQL の型に変換してくれます。
type User = {
// ...
profile: {
// ...
}
}
// → `User` に加え、プロパティ名から推論されて `UserProfile` も生成される。
TypeScript の関数で Query/Mutation 定義・実装
関数定義を gqlkit が提供する defineQuery/defineMutation 関数で包むことにより、それらの関数を Query/Mutation として定義できます。
型パラメタが <TArgs, TResult> となっており、gqlkit cli はこれを参照して GraphQL 上での型定義にマッピングします(NoArgs は引数省略を簡単に表すための utility type)。
export const tasks = defineQuery<NoArgs, Task[]>(
async (_root, _args, ctx) => {
// db からタスクを取り出す関数
const tasks = await fetchTasks(ctx.db);
return tasks;
},
);
type Query {
tasks: [Task!]!
}
また、引数と返り値の型は事前定義せずとも型パラメタに inline で記述することができるようになっています。
export const createTask = defineMutation<
{ input: { title: string } }, // `CreateTaskInput` が生成される
{ task: Task }, // `CreateTaskPayload` が生成される
>(
async (_root, { input }, ctx) => {
// ...
},
);
GraphQL Mutation においては引数, 返り値いずれもその Mutation 専用の型を作るほうが拡張に強くなりやすいとされています。 これを自然に達成できるという点でも inline での引数・返り値の記述は活用できると良いでしょう。
また、上記の例では Object を返していますが Union も利用可能なため、 Errors as Data パターンも実践可能です。
TypeScript の関数で Field 定義・実装
GraphQL はその名のとおり "Graph" です。 Object(node) 間の関連を Field(edge) で表現することができ、クエリ言語を利用してその関連を辿ってデータを取得できます。
ここで選択されなかったフィールド・関連についてはデータを取得すると当然ながら無駄になってしまうでしょう。
export type Post = {
id: IDString;
content: string;
author: User;
};
export const posts = defineQuery<NoArgs, Post[]>(
async (_root, _args, ctx) => {
// Drizzle ORM を利用した実装
return await ctx.db.query.postsTable.findMany({
// GraphQL クエリによらず、posts に関連する users も常に SELECT する
with: { author: true },
});
},
);
このようなとき、 GraphQL schema の実装では resolver を分離して定義することで、選択時のみ実行される処理を記述することができます。 gqlkit ではこれを defineField という関数を使うことで Query/Mutation と同じように記述できます。
export type Post = {
id: IDString;
content: string;
- author: User;
};
export const posts = defineQuery<NoArgs, Post[]>(
async (_root, _args, ctx) => {
return await ctx.db.query.posts.findMany({
- with: { author: true },
});
},
);
+export const author = defineField<Post, NoArgs, User>(
+ async (post, _args, ctx) => {
+ return await ctx.db.query.usersTable.findFirst({
+ where: eq(postsTable.authorId, post.authorId),
+ });
+ },
+);
Server Preset や gqlgen など従来の Schema first ではこの resolver 分離をスキーマ記述時点で考えないといけない、あるいは後からスキーマを変更してコードの再生成が必要でした。 gqlkit ではこれを追加で関数を定義するだけで済みます。 「このフィールドは常に呼ばれるわけじゃないから取得関数を分けよう」という自然な思考の流れ・自然な動作で最適な実装に誘導されます。
DB など別のデータモデルからの GraphQL の型生成
GraphQL Schema は何らかのデータにアクセスするための API として作られることがほとんどでしょう。 すなわち、裏側には別のデータモデルが存在します。 これは resolver から DB に直アクセスしようが中間のモデル層を作ろうが同じはずで、結局は中間モデル <> GraphQL Object の紐付けが必要となるでしょう。
gqlkit は export されている型を見ていい感じに GraphQL 上の型を生成できます。 これはプレーンに記述された型でなくとも対象になります。 例えば Drizzle や Prisma などの ORM を利用している場合、これらが提供する型を gqlkit に認識させることで GraphQL の型に変換できます。
// Drizzle ORM によるテーブル定義
export const postsTable = pgTable("posts", {
id: uuid().primaryKey().defaultRandom(),
content: text(),
priority: text({ enum: ["low", "medium", "high"] }).notNull().default("medium"),
authorId: uuid()
.notNull()
.references(() => users.id),
createdAt: timestamp().notNull().defaultNow(),
});
export type Post = InferSelectModel<typeof postsTable>;
export const author = defineField<Post, NoArgs, User>(
// ...
);
これは Pothos の Backend models や Nexus の Source Types, graphql-codegen の mappers などと同じ概念ですが、gqlkit では「TypeScript の型が GraphQL の型になる」という仕様によりかなりシンプルに表現できてるのではないでしょうか。
Coding Agent 向けのドキュメント, Agent Skill の提供
現代のプログラミング環境で Coding Agent のことを無視するわけにはいかないでしょう。 しかし、当然ながら Coding Agent は knowledge cutoff 後に登場したライブラリ・フレームワークやその新機能については知る由もありませんし、そもそもリリース直後は世の中で使われておらず学習量も極端に少ないため良好な出力を得るのは難しいでしょう。
そこで gqlkit では @gqlkit-ts/cli にそのバージョンのドキュメントを同梱して配布しており、かつそれを参照するための Agent Skill を提供しています。 これは gqlkit cli でも利用している Gunshi を参考にしています。
また、ドキュメントサイトでは llms.txt, llms-full.txt を配信しています。 ちょっと最新の情報が知りたいときなどはこれらを利用することもできます。
おわりに
これまで広く使われてきた TypeScript での GraphQL Schema 構築ライブラリたちの特徴を改めて整理し、人間からも Coding Agent からもよりシンプルに利用できる新たなライブラリとして gqlkit を紹介しました。
もし gqlkit の設計や思想を良いと思ってもらえたならぜひお試しください。 困ったことがあればお気軽にお声がけください。
あ、あと、スターもいただけると喜びます。
Discussion