しんのきです。
自社サービスで Apollo Client の fetchMore
を使ったページネーションを実装する際、出たばかりの TypedDocumentNode を思い出し、これを使えばもっとシンプルに型定義できそうだと思って調査してみました。
結果としては大きなハマりどころもなく期待通り動いてくれました。
(ちなみにドキュメントにもまだ反映されていませんが、 fetchMore
と updateQueries
を使ったページネーションは Apollo Client v3 で deprecated になっています。これはまた別のお話。)
TypedDocumentNode とは
TypedDocumentNode は graphql-codegen
など GraphQL 関連のツール群を作っている The Guild が 2020 年 7 月にリリースした、 GraphQL ライブラリ用の TypeScript の型定義パッケージです。
型定義のみのパッケージですので何か新しい機能があるわけではありませんが、多数存在する GraphQL ライブラリ間で統一的な型定義を提供することによって graphql-codegen
や他の GraphQL ライブラリ自体のメンテナンス性を上げることを目的としています。
下記の issue がベースのアイディアとなっています。
各ライブラリの対応状況として、 Apollo Client では v3.2.0(現時点ではまだbeta)から標準で対応されますが、それ以前のバージョンや他のライブラリでも @graphql-typed-document-node/patch-cli
を用いてパッチを当てることで利用可能になります。
使い方
graphql-codegen
をセットアップ後、例えば React と Apollo Client を使用している場合は typescript-react-apollo
プラグインを設定しますが、これを typed-document-node
に変更します。
schema: SCHEMA_FILE_OR_ENDPOINT_HERE documents: "./src/**/*.graphql" generates: ./src/graphql-operations.ts: plugins: - typescript - typescript-operations - typed-document-node
https://graphql-code-generator.com/docs/plugins/typed-document-node#usage-example
注意点として、現時点では typescript-react-apollo
と typed-document-node
を同時に設定してしまうと DocumentNode の定義が重複してエラーになってしまいます。
すでに typescript-react-apollo
を使っていて段階的に移行したい場合、同じファイルに両方出力するのではなく別々のファイルに出力する必要があります。
これについては issue が上がっているので近いうちに対応されるかもしれません。
今までの graphql-codegen との違い
例えば以下のような Query を定義したとします。(user_by_pk
は Hasura を使った場合の例です)
query User($id: uuid!) { user: user_by_pk(id: $id) { id name } }
typescript-react-apollo
を使うと以下のようなコードが生成されます。
import { gql } from "@apollo/client"; import * as Apollo from "@apollo/client"; export type Maybe<T> = T | null; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K]; }; export type Scalars = { ID: string; String: string; Boolean: boolean; Int: number; Float: number; uuid: string; }; export type User = { id: Scalars["uuid"]; name: Scalars["String"]; }; export type UserQueryVariables = Exact<{ id: Scalars["uuid"]; }>; export type UserQuery = { user?: Maybe<Pick<User, "id" | "name">> }; export const UserDocument = gql` query User($id: uuid!) { user: user_by_pk(id: $id) { id name } } `; export function useUserQuery( baseOptions?: Apollo.QueryHookOptions<UserQuery, UserQueryVariables> ) { return Apollo.useQuery<UserQuery, UserQueryVariables>( UserDocument, baseOptions ); }
これをコンポーネント側で呼び出す際は以下のように生成されたカスタム Hook を呼び出します。
import { useUserQuery } from "./generated"; // ... const { data, error, loading } = useUserQuery({ variables: { id: userId, }, })
一方、 typed-document-node
を使うと以下のようなコードが生成されます。
import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core"; // 同じなので省略 export const UserDocument: DocumentNode<UserQuery, UserQueryVariables> = { kind: "Document", definitions: [ { kind: "OperationDefinition", operation: "query", name: { kind: "Name", value: "User" }, variableDefinitions: [ { kind: "VariableDefinition", variable: { kind: "Variable", name: { kind: "Name", value: "id" } }, type: { kind: "NonNullType", type: { kind: "NamedType", name: { kind: "Name", value: "uuid" } }, }, directives: [], }, ], directives: [], selectionSet: { kind: "SelectionSet", selections: [ { kind: "Field", alias: { kind: "Name", value: "user" }, name: { kind: "Name", value: "user_by_pk" }, arguments: [ { kind: "Argument", name: { kind: "Name", value: "id" }, value: { kind: "Variable", name: { kind: "Name", value: "id" }, }, }, ], directives: [], selectionSet: { kind: "SelectionSet", selections: [ { kind: "Field", name: { kind: "Name", value: "id" }, arguments: [], directives: [], }, { kind: "Field", name: { kind: "Name", value: "name" }, arguments: [], directives: [], }, ], }, }, ], }, }, ], };
UserDocument
の中身が長いですが、これは typescript-react-apollo
で export const UserDocument = gql`...`
で定義した結果と同じです。
注目するべきは DocumentNode がクエリの返り値と引数の型を知っているということです。
コンポーネント側では以下のように呼び出します。
import { useQuery } from "@apollo/client"; import { UserDocument } from "./generated"; // ... const { data, error, loading } = useQuery(UserDocument, { variables: { id: userId, }, });
UserDocument
を渡した時点で useQuery
の型が推論され、 useUserQuery
と同じように data
と variables
に型が付与されたに変化します。
メリット
インタフェースが統一される
上記の例で分かる通り typed-document-node
で生成されたコード内には @apollo/client
などの特定の GraphQL ライブラリへの依存がありません。
TypeScript で graphql-codegen
を使いたければ typed-document-node
だけ理解すればあとのライブラリは自由に選べるという作りになっています。
またライブラリを作る側としてのメリットも大きく、今後 TypedDocumentNode を中心としたエコシステムでライブラリの改善スピードが上がる可能性があります。
Hooks 以外の型付与がやりやすくなる(React + Apollo Client の場合)
Apollo Client ではキャッシュを考慮しながら作り込んでいくとカスタム Hook 以外で GraphQL クエリを発行しなければいけない場面がいくつかあります。
具体的には fetchMore
, subscribeToMore
, refetchQueries
がこれに該当するのですが、 これらの関数に対しても TypedDocumentNode を渡した時点で型推論が行われるようになるため、型付与がやりやすくなります。
import { useQuery } from "@apollo/client"; import { SampleOneDocument, SampleTwoDocument } from "./generated"; const { data, error, loading, fetchMore } = useQuery(SampleOneDocument); const onLoadMore = async () => { await fetchMore({ query: SampleTwoDocument, updateQuery: (prev, { fetchMoreResult }) => { // prev は SampleOneQuery, fechMoreResult は SampleTwoQuery | undefined } }); }
まとめ
TypedDocumentNode は実際触ってみてもライブラリの製作者にとっても利用者にとってもメリットがある仕組みだと感じました。
このように既存のシステムが出来上がってしまっているものに異を唱えて最適化できる人はすごいですね。
大きなデメリットは特に無いように思いますが、強いて言えば useFooQuery
, useBarMutation
となっていたものが全て FooDocument
になってしまうため見分けが付きづらいことでしょうか。
この辺りも typed-document-node
のオプションとして改善されていきそうな気がしています。
最後に宣伝です。
こんな感じの技術調査タスクが溜まっているので、 Discord でつなぎながらもくもくする会を企画しています。
興味がございましたらぜひご参加ください!