Quantcast
Channel: クックパッド開発者ブログ
Viewing all articles
Browse latest Browse all 726

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

$
0
0

技術部の外村(@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.ymlschema: schema.graphql
documents: graphql/**/*.graphql
generates:lib/generated/client.ts:plugins:- typescript
      - typescript-operations

documentsには、リクエストするときに発行するクエリを記述したファイルを指定します。スキーマとクエリは次のようになっていたとします。

# schema.graphqltypeRecipe{id: Int!title: String!imageUrl: String}typeQuery{recipe(id: Int!): Recipe!}
# graphql/getRecipe.graphqlquerygetRecipe($id: Int!) {recipe(id: $id) {idtitleimageUrl}}

getRecipe.graphqlはアプリケーションから発行するクエリです。アプリケーションのコード内に書くのでなく、ファイルを分けて書いています。

これで graphql-codegenコマンドを実行すると以下のコードが生成されます。

exporttype Maybe<T>= T | null;exporttype Exact<T extends{[key: string]: unknown }>={[K in keyof T]: T[K]};exporttype MakeOptional<T, K extends keyof T>= Omit<T, K>& {[SubKey in K]?: Maybe<T[SubKey]>};exporttype 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 */exporttype Scalars ={
  ID: string;String: string;Boolean: boolean;
  Int: number;
  Float: number;};exporttype Recipe ={
  __typename?: 'Recipe';
  id: Scalars['Int'];
  title: Scalars['String'];
  imageUrl?: Maybe<Scalars['String']>;};exporttype Query ={
  __typename?: 'Query';
  recipe: Recipe;};exporttype QueryRecipeArgs ={
  id: Scalars['Int'];};exporttype GetRecipeQueryVariables = Exact<{
  id: Scalars['Int'];}>;exporttype GetRecipeQuery =({ __typename?: 'Query'}& { recipe: ({ __typename?: 'Recipe'}& Pick<Recipe,'id' | 'title' | 'imageUrl'>)});

このとき、スキーマとクエリに型の不整合があればコード生成のときにエラーになるので、スキーマに違反するようなクエリを書くことができません。例えばクエリを以下のようにしてみます。

querygetRecipe($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.ymlschema: schema.graphql
documents: graphql/**/*.graphql
generates:lib/generated/client.ts:plugins:- typescript
      - typescript-operations
      - typescript-graphql-request

これでもう一度 graphql-codegenを実行すると、先程の生成したファイルに追加して以下のようなコードが生成されます。

exportconst GetRecipeDocument = gql`    query getRecipe($id: Int!) {  recipe(id: $id) {    id    title    imageUrl  }}    `;exporttype SdkFunctionWrapper =<T>(action: ()=> Promise<T>)=> Promise<T>;const defaultWrapper: SdkFunctionWrapper = sdkFunction => sdkFunction();exportfunction getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper){return{
    getRecipe(variables: GetRecipeQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise<GetRecipeQuery>{return withWrapper(()=> client.request<GetRecipeQuery>(GetRecipeDocument, variables, requestHeaders));}};}exporttype 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);asyncfunction 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 existreturn response.recipe;}

このように、リクエストとレスポンスに対して自動生成された型がつきます。また、クエリの入力部分は query getRecipe($id: Int!)でしたが、この入力パラメータについても型がつきます。

これでクライアントにおけるリクエストとレスポンスに型がつきました。

サーバーサイド

次にサーバーサイドです。サーバーサイドでは以下のプラグインを使います。

@graphql-codegen/typescript-resolversが GraphQL サーバーの Resolver の型を自動生成するためのプラグインです。

設定ファイルは次のようにします。

# codegen.ymlschema: schema.graphql
generates:lib/generated/resolvers.ts:plugins:- typescript
      - typescript-resolvers

これで生成された型定義を使って Resolver を次のように書きます。

import{ Resolvers }from"./generated/resolvers";exportconst 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 呼び出しに型がなくて疲れてしまい、このような環境で開発してみたくなった方はお気軽にお問い合わせください!


Viewing all articles
Browse latest Browse all 726

Trending Articles