💁‍♀️

graphql-codegen Client Preset 時代(v3~)の おすすめ設定 for TypeScript

2023/07/07に公開

7月はLayerX エンジニアブログを活発にする期間です。今日は誰がなんと言おうと 7/6 です。
昨日 7/5 は「Datadog のコスト最適化で月額費用を 30% 削減した」でした。


2022年10月3日に、GraphQL Code Generator(以下、graphql-codegen)のおすすめ設定に関する記事を公開しました。
https://zenn.dev/izumin/articles/ffc84c1b4310be

しかし、その後、graphql-codegen v3のリリースに伴い、ReactやVueではclient-presetが推奨されるようになりました。
client-preset前提の環境では、利用可能な設定項目などがいくつか変わるため、本記事では改めて、graphql-codegen以降のClient preset時代のおすすめ設定をまとめています。

全体

設定を TypeScript で書く

graphql-codegen v3から公式ドキュメントで案内される設定ファイルが codegen.ts になりました。
従来でもJSDocを駆使すれば型の恩恵を受けることができましたが、TypeScriptがサポートされたことで慣れた記法で高い表現力の恩恵を得られるようになりました。

例えば、satisfies などが使えるため、プラグインの設定などにも型を効かせられるのが便利です。
補完や型チェックも嬉しいですが、Hoverや定義元ジャンプで設定項目のドキュメントを確認できるのも便利です。

codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
import type { TypeScriptDocumentsPluginConfig } from "@graphql-codegen/typescript-operations";

const config: CodegenConfig = {
  generates: {
    "./src": {
      plugins: [
        {
          "typescript-operations": {
            // ここで補完や型チェック, 定義元ジャンプが効く!
          } satisfies TypeScriptDocumentsPluginConfig,
        },
      ],
    },
  },
};

export default config;

設定は GraphQL Config でも書ける

Client preset の登場以前からあるプラクティスですが、前回の記事で紹介し忘れていました。

GraphQL Config は graphql-codegen と同様に The Gulid がメンテナンスしているパッケージです。
GraphQL 関連ツールの設定を1ファイルに集約することがモチベーションです。
つまり、graphql-codegen の設定は codegen.{js,ts,yml} だけでなく、GraphQL Config で記述することもできます。

他にツールを使用していない場合は、codegen.ts で十分だと思われるかもしれませんが、GraphQL の LSP (graphql-language-service-server) は GraphQL Config を認識しています
VSCode の GraphQL 拡張機能neovim-lspconfig などでも使用されており、スキーマ定義やクエリの記述時に補完や定義元ジャンプが使用可能になります。
以前から.graphqlrc.yml などに記述していた方も多いかもしれません。

graphql.config.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
import type { IGraphQLConfig } from "graphql-config";

const codegenConfig: CodegenConfig = {
  // `schema`, `documents` は GraphQL Config 側にあるので不要
}

const config: IGraphQLConfig = {
  schema: "path/to/schema.graphql",
  documents: ["path/to/documents/**/*.{ts,tsx}"],
  extensions: {
    codegen: codegenConfig,
  },
};

module.exports = config;

schemadocumentsはLanguage Serverとgraphql-codegenで共通の設定になることが多いため、共通の設定にすると管理が容易になるかもしれません。

(ところで、筆者の環境だと graphql-config を TypeScript で書くと nvim-lspconfig で有効化してる GraphQL Service が反応しなくなって困っています。なんでや。)

生成物をフォーマッタにかける

これは前回の記事と同じです。開発者が定義元ジャンプで生成されたコードを読むことがあるため、見やすく整えることには価値があります。

const codegenConfig: CodegenConfig = {
  // ...

  hooks: {
    afterAllFileWrite: ["prettier --write"],
  },
}

Client preset

ここでは前回記事との差分を紹介する。
差分は以下の4パターン。

  • Client preset で使えない設定
  • 使えなくなった設定の代替になるもの
  • 新たに有用性に気づいたもの
  • Client preset で新たに追加されたもの

Client preset で使える設定・使えない設定

最初に紹介したとおり、graphql-codegen v3以降のドキュメントでは、ReactやVue向けにClient presetを紹介するようになっています。
https://the-guild.dev/graphql/codegen/docs/guides/react-vue

Client preset自体については、以下の記事が詳しいです(日本語)。従来とは大きく異なっています。
https://zenn.dev/mh4gf/articles/graphql-codegen-client-preset

先の記事で述べられている、Client presetでは、 typescript plugintypescript-operation plugin の設定の一部のみがサポートされています。

前回の記事で紹介した設定との差分は以下の通りです。

  • 引き続き利用可能
    • defaultScalarType
    • scalars
    • strictScalars
    • skipTypename
    • avoidOptionals
  • 未サポート
    • immutableTypes
    • enumsAsConst

利用可能な設定の一覧は、Client presetのドキュメントまたは実装を参照してください。

enumsAsType: TypeScript の enum を使わない

前回の記事では、enumsAsConst設定をおすすめしましたが、その代替となる設定です。
生成されるコードが、単なるunionか、as constから組み立てたものかが異なります。

enumsAsType と enumsAsConst の違い

コードは以下の scrap より引用。

https://zenn.dev/nbstsh/scraps/3891734f2cfc29

enumsAsType

export type Role =
  | 'ADMIN'
  | 'EDITOR'
  | 'VIEWER';

enumsAsConst

export const Role = {
  Admin: 'ADMIN',
  Editor: 'EDITOR',
  Viewer: 'VIEWER'
} as const;

export type Role = typeof Role[keyof typeof Role];

個人的には enumsAsConst のほうが好きだったけど、typescript plugin の出力が型だけにしたほうがいい気はするので納得。enumsAsConst が復活したとしても enumsAsType を使い続けるかも。

useTypeImports: import type を使う

新しく有用性に気づいたもの。

生成ファイル内で型定義を import するときは import type を使ってくれるようになります。

@@ -1,6 +1,6 @@
 /* eslint-disable */
 import * as types from "./graphql";
-import type { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core";
+import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core";

import type はトランスパイル時に何も考えずに削除できるので、トランスパイラに優しいです。

https://zenn.dev/teppeis/articles/2023-04-typescript-5_0-verbatim-module-syntax

とくにデメリットが思いつかないので、とりあえず有効にしていいんじゃないでしょうか。

Fragment Masking

useFragment 関数が生成されます。これにより、Fragment 内に定義されていないフィールドが型に現れなくなります。ただし、ランタイムでマスキングしているわけではないため、JSON.stringify() などを使用すると、隣の Fragment にしか含まれていないフィールドも出力される可能性があります。

https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#fragment-masking

これにより、GraphQL の「over-fetching の抑制」というメリットを仕組みで守ることができます。

Client preset おすすめ設定まとめ

import type { CodegenConfig } from "@graphql-codegen/cli";
import type { TypeScriptDocumentsPluginConfig } from "@graphql-codegen/typescript-operations";

// Client preset と MSW 用の Operation plugin に共通の設定
const operationConfig: TypeScriptDocumentsPluginConfig = {
};

const codegenConfig: CodegenConfig = {
  // ...
  generates: {
   "./src/gql/__generated__/": {
      preset: "client",
      plugins: [
        {
	  // Custom Scalar の branded type 定義
          add: {
            content: `export type DateString = string & { readonly __brand: unique symbol }`,
          },
        },
      ],
      config: {
        strictScalars: true,
        useTypeImports: true,
        skipTypename: true,
        arrayInputCoercion: true,
        avoidOptionals: {
          field: true,
          inputValue: false,
          object: true,
          defaultValue: false,
        },
        scalars: {
          Date: "DateString",
        },
        enumsAsTypes: true,
      },
    },
    // ...
  },
};

typescript-msw plugin

typescript-msw については前回の記事でも紹介しました
周辺の設定が少し改良されたので、ここで紹介します。

near-operation-file preset と組み合わせる

near-operation-files については前回の記事で紹介しています
アプリケーション実装上では、Client preset があるため、この preset は不要ですが、typescript-msw プラグインなどの範囲外の場合は、近くの operation file に設定することが望ましいです。
特に、Fragment Colocation を使用している場合は、Query や Mutation のモックを使用するため、MSW handler などはディレクトリ構造的に近くに生成されると便利です。

typescript-operation plugin と組み合わせる

重要なポイント。
typescript-msw は Operation の mock handler を生成するため、Operation の型定義が必要です、
ただ、Client preset で Fragment Masking が有効になっている場合、Fragment に隠蔽されたフィールドが親から見えなくなります。
Masking された Fragment が mock handler で使われてしまうと、handler の実装をするときに非常に不便です。

Fragment に隠蔽されたフィールドは Query からは見えない
fragment UserListItem on User {
  id
  name
}

query ListUsers {
  users {
    id
    ...UserListItem
  }}
}
mockListUsersQuery(() => {
  return res(
    ctx.data({ // <- ListUsers query のレスポンスの型
      users: {
        id: "123",
	// `name` は fragment 内でしか使えないので型エラーになる
	name: "Taro Test",
      }
    }),
  );
});

Client preset が生成しているヘルパ型を利用すれば剥がせなくはないですが、mock はもうちょっと楽に書きたいというのが本音です。

そこで、MSW で利用する専用の Operation の型定義を一緒に生成してあげることで、プレーンな Operation の型をモックで利用できるようになります。

MSW と組み合わせる際の typescript-operations おすすめ設定

ベースは Client preset と同じなので、差分だけ簡単に紹介。

ベースは Client preset と同じですが、差分だけ簡単に説明します。

  • nonOptionalTypename
    • Apollo Client などは、レスポンスに __typename が含まれていないと正常に動作しないため、暗黙的に __typename を追加しています。
    • そのため、MSW のレスポンスには、必ず __typename を含めておく必要があります
  • skipTypeNameForRoot
    • QueryMutation など、ルートのオブジェクトの __typename は必要ありません
  • scalars
    • Client preset 側で Branded Type を定義している場合、それを import して使わなければなりません
    • near-operation-files preset の importTypesNamespace で設定した文字列をプレフィクスに使用する必要があります
      • 例: { Date: "Types.Date" }

MSW 用のおすすめ設定まとめ

import type { CodegenConfig } from "@graphql-codegen/cli";
import type { TypeScriptDocumentsPluginConfig } from "@graphql-codegen/typescript-operations";

// Client preset と MSW 用の Operation plugin に共通の設定
const operationConfig: TypeScriptDocumentsPluginConfig = {
};

const codegenConfig: CodegenConfig = {
  // ...
  generates: {
    // ...
    "./src/": {
      preset: "near-operation-file",
      presetConfig: {
        folder: "__generated__",
        extension: ".msw.ts",
	// typescript plugin の出力(スキーマから生成する型)を参照する
        baseTypesPath: "./__generated__/gql/graphql.ts",
        importTypesNamespace: "Types",
      },
      plugins: [
        {
          "typescript-operations": {
            ...operationConfig, 
            nonOptionalTypename: true,
            skipTypeNameForRoot: true,
            scalars: {
	      // Client preset の `scalars` をベースに `Types.` を付与
	      Date: "Types.Date",
	    },
          } satisfies TypeScriptDocumentsPluginConfig,
        },
        "typescript-msw",
      ],
    },
  },
};

fragment-matcher plugin

前回記事で書き忘れてたんだけど、必要なことが多いのでここで紹介。

Apollo client などで Abstraction Type (Interface, Union)が含まれるスキーマを扱う場合、Introspection の possibleTypes を渡してあげる必要があります。それを生成するために、fragment-matcher plugin を使用します。

const codegenConfig: CodegenConfig = {
  // ...
  generates: {
    // ...
    "./src/__generated__/gql/introspection.ts": {
      plugins: ["fragment-matcher"],
      config: {
        apolloClientVersion: 3,
        useExplicitTyping: true,
      },
    },
  },
};

さいごに

みなさんのオススメ設定もあれば是非コメントして下さい!

LayerX

Discussion