技術部の外村(@hokaccha)です。
レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 - クックパッド開発者ブログ
という記事を書きましたが、この中で詳しく説明しなかった GraphQL のスキーマやクエリから TypeScript の型定義を自動生成する仕組みについて紹介します。
なお、今回紹介したコードは以下で試せます。
https://github.com/hokaccha/graphql-codegen-example-for-techlife
GraphQL Code Generator を使った型生成
GraphQL のスキーマから TypeScript の型を生成するためのライブラリはいくつかあります。
などが有名どころです。今回はシンプルさや拡張性を考えて GraphQL Code Generator を採用したので、GraphQL Code Generator を使ったコード生成について紹介します。
GraphQL Code Generator 自身は TypeScript 以外の言語に対応していたり、TypeScript の中でも様々な機能のプラグインが提供されており、その中から自分の用途にあったプラグインを組み合わせを選ぶことになります。
今回は自動生成で以下の目的を達成します。
- クライアントサイドで発行するリクエストとレスポンスに TypeScript の型をつける
- サーバーサイドの Resolver に TypeScript の型をつける
クライアントサイドとサーバーサイドで利用するプラグインや実装は分断されるのでそれぞれ分けて解説します。
クライアントサイド
まずはクライアントサイドです。使っているプラグインは以下です。
- @graphql-codegen/typescript
- @graphql-codegen/typescript-operations
- @graphql-codegen/typescript-graphql-request
@graphql-codegen/typescript
は TypeScript の型生成する場合に必用なプラグインで TypeScript のコードを生成する場合に必用です。@graphql-codegen/typescript-operations
は GraphQL のクエリとスキーマを元に TypeScript の型を自動生成します。
設定ファイルは次のようになります。
# codegen.yml schema: schema.graphql documents: graphql/**/*.graphql generates: lib/generated/client.ts: plugins: - typescript - typescript-operations
documents
には、リクエストするときに発行するクエリを記述したファイルを指定します。スキーマとクエリは次のようになっていたとします。
# schema.graphql type Recipe { id: Int! title: String! imageUrl: String } type Query { recipe(id: Int!): Recipe! }
# graphql/getRecipe.graphql query getRecipe($id: Int!) { recipe(id: $id) { id title imageUrl } }
getRecipe.graphql
はアプリケーションから発行するクエリです。アプリケーションのコード内に書くのでなく、ファイルを分けて書いています。
これで graphql-codegen
コマンドを実行すると以下のコードが生成されます。
export type Maybe<T> = T | null; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }; export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }; /** All built-in and custom scalars, mapped to their actual values */ export type Scalars = { ID: string; String: string; Boolean: boolean; Int: number; Float: number; }; export type Recipe = { __typename?: 'Recipe'; id: Scalars['Int']; title: Scalars['String']; imageUrl?: Maybe<Scalars['String']>; }; export type Query = { __typename?: 'Query'; recipe: Recipe; }; export type QueryRecipeArgs = { id: Scalars['Int']; }; export type GetRecipeQueryVariables = Exact<{ id: Scalars['Int']; }>; export type GetRecipeQuery = ( { __typename?: 'Query' } & { recipe: ( { __typename?: 'Recipe' } & Pick<Recipe, 'id' | 'title' | 'imageUrl'> ) } );
このとき、スキーマとクエリに型の不整合があればコード生成のときにエラーになるので、スキーマに違反するようなクエリを書くことができません。例えばクエリを以下のようにしてみます。
query getRecipe($id: Int!) { recipe(id: $id) { foo } }
これで graphql-codegen
を実行するとこのようにエラーになります。
$ npx graphql-codegen ✔ Parse configuration ❯ Generate outputs ❯ Generate lib/generated/client.ts ✔ Load GraphQL schemas ✔ Load GraphQL documents ✖ Generate → at ~/local/src/github.com/hokaccha/graphql-codegen-example-for-techlife/client/graphql/getRecipe.graphql:3:5 Found 1 error ✖ lib/generated/client.ts AggregateError: GraphQLDocumentError: Cannot query field "foo" on type "Recipe". (snip)
これだけでもだいぶ便利ですね。
しかし、まだこれだけだと型が生成されただけでアプリケーション内でリクエストとレスポンスに型を与えることはできていません。それを実現するのが @graphql-codegen/typescript-graphql-request
です。これは graphql-request というライブラリをベースにしたクライントを自動で生成してくれます。
他にも react-query をベースにして React hooks として使える @graphql-codegen/typescript-react-query など、いくつか選択肢はありますが、今回は SSR でも同じ用に使えてできるだけシンプルなものという理由で @graphql-codegen/typescript-graphql-request
を選択しました。
設定ファイルは plugins
に typescript-graphql-request
を足すだけです。
# codegen.yml schema: schema.graphql documents: graphql/**/*.graphql generates: lib/generated/client.ts: plugins: - typescript - typescript-operations - typescript-graphql-request
これでもう一度 graphql-codegen
を実行すると、先程の生成したファイルに追加して以下のようなコードが生成されます。
export const GetRecipeDocument = gql` query getRecipe($id: Int!) { recipe(id: $id) { id title imageUrl } } `; export type SdkFunctionWrapper = <T>(action: () => Promise<T>) => Promise<T>; const defaultWrapper: SdkFunctionWrapper = sdkFunction => sdkFunction(); export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) { return { getRecipe(variables: GetRecipeQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise<GetRecipeQuery> { return withWrapper(() => client.request<GetRecipeQuery>(GetRecipeDocument, variables, requestHeaders)); } }; } export type Sdk = ReturnType<typeof getSdk>;
先程別ファイルにした getRecipe
クエリもこの自動生成コードに含まれており、getRecipe()
でこのクエリを使ってリクエストし、レスポンスにも型がつきます。アプリケーションからはこのように使います。
import { GraphQLClient } from "graphql-request"; import { useEffect, useState } from "react"; import { getSdk, Recipe } from "./generated/client"; const client = new GraphQLClient("http://localhost:8000/graphql"); const sdk = getSdk(client); async function getRecipe(id: number) { const response = await sdk.getRecipe({ id }); // const response = await sdk.getRecipe(); // Error: Expected 1-2 arguments, but got 0. console.log(response.recipe.id); // id: number console.log(response.recipe.title); // title: string console.log(response.recipe.imageUrl); // imageUrl?: string // @ts-expect-error console.log(response.recipe.foo); // Property 'foo' does not exist return response.recipe; }
このように、リクエストとレスポンスに対して自動生成された型がつきます。また、クエリの入力部分は query getRecipe($id: Int!)
でしたが、この入力パラメータについても型がつきます。
これでクライアントにおけるリクエストとレスポンスに型がつきました。
サーバーサイド
次にサーバーサイドです。サーバーサイドでは以下のプラグインを使います。
@graphql-codegen/typescript-resolvers
が GraphQL サーバーの Resolver の型を自動生成するためのプラグインです。
設定ファイルは次のようにします。
# codegen.yml schema: schema.graphql generates: lib/generated/resolvers.ts: plugins: - typescript - typescript-resolvers
これで生成された型定義を使って Resolver を次のように書きます。
import { Resolvers } from "./generated/resolvers"; export const resolvers: Resolvers = { Query: { recipe: async (_parent, args, _context, _info) => { return { id: args.id, title: "recipe title", imageUrl: null, }; }, }, };
これだけです。args
に入力値が渡ってきますが、これにはスキーマで指定されている id: Int!
の型が渡ってきます。もちろん返り値もチェックされているので返している値がスキーマと整合性が取れていないと型エラーになります。
この Resolver は、graphql-tools の makeExecutableSchema
にそのまま渡せる型として定義されます。ですので、makeExecutableSchema
で作った schema をそのまま実行できる Apollo や graphql-express などで実行します。以下は graphql-express の例です。
import fs from "fs"; import express from "express"; import cors from "cors"; import { makeExecutableSchema } from "graphql-tools"; import { graphqlHTTP } from "express-graphql"; import { resolvers } from "./lib/resolvers"; const typeDefs = fs.readFileSync("./schema.graphql", { encoding: "utf8" }); const schema = makeExecutableSchema({ typeDefs, resolvers, }); const app = express(); app.use(cors()); app.use("/graphql", graphqlHTTP({ schema })); app.listen(8000, () => { console.log("listen: http://localhost:8000"); });
これでサーバーサイドにも型がつきました。
まとめ
GraphQL のスキーマやクエリから TypeScript の型定義やクライアントを自動生成する方法について紹介しました。実際にクックパッドのアプリケーションでもほぼ同じ仕組みで動いています。できるだけシンプルに寄せるため Apollo などは使っておらず必要最低限にしていますが、GraphQL や TypeScript の強力な型付けの恩恵を受けることができて非常に便利です。
また、現状では Apollo や React Query などは組み合わせて使っておらず、キャッシュや hooks 化などは必要に応じてやっていますが、GraphQL Code Generator はそのあたりのプラグインも豊富で変更したいとなったときにプラグインの追加で気軽に構成変更できるのも便利です。
まだまだこのあたりも環境整備も発展途上ですので我こそは最高の環境を作るぞという方、もしくは API 呼び出しに型がなくて疲れてしまい、このような環境で開発してみたくなった方はお気軽にお問い合わせください!