あ、しんのきです

趣味とか技術系とか

@graphql-codegen/typed-document-node を使ってみた

しんのきです。

自社サービスで Apollo Client の fetchMore を使ったページネーションを実装する際、出たばかりの TypedDocumentNode を思い出し、これを使えばもっとシンプルに型定義できそうだと思って調査してみました。

結果としては大きなハマりどころもなく期待通り動いてくれました。

(ちなみにドキュメントにもまだ反映されていませんが、 fetchMoreupdateQueries を使ったページネーションは Apollo Client v3 で deprecated になっています。これはまた別のお話。)

TypedDocumentNode とは

TypedDocumentNode は graphql-codegen など GraphQL 関連のツール群を作っている The Guild が 2020 年 7 月にリリースした、 GraphQL ライブラリ用の TypeScript の型定義パッケージです。

the-guild.dev

型定義のみのパッケージですので何か新しい機能があるわけではありませんが、多数存在する GraphQL ライブラリ間で統一的な型定義を提供することによって graphql-codegen や他の GraphQL ライブラリ自体のメンテナンス性を上げることを目的としています。

下記の issue がベースのアイディアとなっています。

[TypeScript] Proposal: unified output for clients · Issue #1777 · dotansimha/graphql-code-generator · GitHub

各ライブラリの対応状況として、 Apollo Client では v3.2.0(現時点ではまだbeta)から標準で対応されますが、それ以前のバージョンや他のライブラリでも @graphql-typed-document-node/patch-cli を用いてパッチを当てることで利用可能になります。

github.com

使い方

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-apollotyped-document-node を同時に設定してしまうと DocumentNode の定義が重複してエラーになってしまいます。
すでに typescript-react-apollo を使っていて段階的に移行したい場合、同じファイルに両方出力するのではなく別々のファイルに出力する必要があります。

これについては issue が上がっているので近いうちに対応されるかもしれません。

Migration from react-apollo to typed-document-node · Issue #4511 · dotansimha/graphql-code-generator · GitHub

今までの 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-apolloexport 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 と同じように datavariables に型が付与されたに変化します。

メリット

インタフェースが統一される

上記の例で分かる通り 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 でつなぎながらもくもくする会を企画しています。
興味がございましたらぜひご参加ください!

wasd-inc.connpass.com