graphql-codegen Client Preset 時代(v3~)の おすすめ設定 for TypeScript
7月はLayerX エンジニアブログを活発にする期間です。今日は誰がなんと言おうと 7/6 です。
昨日 7/5 は「Datadog のコスト最適化で月額費用を 30% 削減した」でした。
2022年10月3日に、GraphQL Code Generator(以下、graphql-codegen)のおすすめ設定に関する記事を公開しました。
しかし、その後、graphql-codegen v3のリリースに伴い、ReactやVueではclient-presetが推奨されるようになりました。
client-preset前提の環境では、利用可能な設定項目などがいくつか変わるため、本記事では改めて、graphql-codegen以降のClient preset時代のおすすめ設定をまとめています。
全体
設定を TypeScript で書く
graphql-codegen v3から公式ドキュメントで案内される設定ファイルが codegen.ts
になりました。
従来でもJSDocを駆使すれば型の恩恵を受けることができましたが、TypeScriptがサポートされたことで慣れた記法で高い表現力の恩恵を得られるようになりました。
例えば、satisfies
などが使えるため、プラグインの設定などにも型を効かせられるのが便利です。
補完や型チェックも嬉しいですが、Hoverや定義元ジャンプで設定項目のドキュメントを確認できるのも便利です。
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
などに記述していた方も多いかもしれません。
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;
schema
とdocuments
は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を紹介するようになっています。
Client preset自体については、以下の記事が詳しいです(日本語)。従来とは大きく異なっています。
先の記事で述べられている、Client presetでは、 typescript
plugin や typescript-operation
plugin の設定の一部のみがサポートされています。
前回の記事で紹介した設定との差分は以下の通りです。
- 引き続き利用可能
defaultScalarType
scalars
strictScalars
skipTypename
avoidOptionals
- 未サポート
immutableTypes
enumsAsConst
利用可能な設定の一覧は、Client presetのドキュメントまたは実装を参照してください。
enumsAsType
: TypeScript の enum を使わない
前回の記事では、enumsAsConst
設定をおすすめしましたが、その代替となる設定です。
生成されるコードが、単なるunion
か、as const
から組み立てたものかが異なります。
enumsAsType と enumsAsConst の違い
コードは以下の scrap より引用。
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
はトランスパイル時に何も考えずに削除できるので、トランスパイラに優しいです。
とくにデメリットが思いつかないので、とりあえず有効にしていいんじゃないでしょうか。
Fragment Masking
useFragment
関数が生成されます。これにより、Fragment 内に定義されていないフィールドが型に現れなくなります。ただし、ランタイムでマスキングしているわけではないため、JSON.stringify()
などを使用すると、隣の Fragment にしか含まれていないフィールドも出力される可能性があります。
これにより、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 の型をモックで利用できるようになります。
typescript-operations
おすすめ設定
MSW と組み合わせる際の ベースは Client preset と同じなので、差分だけ簡単に紹介。
ベースは Client preset と同じですが、差分だけ簡単に説明します。
-
nonOptionalTypename
- Apollo Client などは、レスポンスに
__typename
が含まれていないと正常に動作しないため、暗黙的に__typename
を追加しています。 - そのため、MSW のレスポンスには、必ず
__typename
を含めておく必要があります
- Apollo Client などは、レスポンスに
-
skipTypeNameForRoot
-
Query
やMutation
など、ルートのオブジェクトの__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,
},
},
},
};
さいごに
みなさんのオススメ設定もあれば是非コメントして下さい!
Discussion