GraphQL Code Generator で TypeScript の型を自動生成する

技術部の外村(@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 は 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 を選択しました。

設定ファイルは pluginstypescript-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-toolsmakeExecutableSchema にそのまま渡せる型として定義されます。ですので、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 呼び出しに型がなくて疲れてしまい、このような環境で開発してみたくなった方はお気軽にお問い合わせください!

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/