バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

apollo-link-scalars を使ってApollo ClientでCustom Scalarをいい感じに扱う

こんにちは、開発2部の早瀬です。

自分のチームではGraphQLを採用しており、クライアントライブラリにApollo Clientを使用しています。その中でスキーマの一部でCustom Scalarを使用しているのですが、Apollo ClientでCustom Scalarを扱うために少し工夫が必要だったので、今回はその方法を紹介します!

背景

自分のチームでは冒頭で紹介したApollo Clientに加えて、型の生成にGraphQL Code Generatorを採用しています。GraphQL Code Generatorを使用して型を生成する場合、Custom Scalarはデフォルトだとany型で生成されます。この挙動は下記のように設定することで、Custom Scalarに対応するTypeScriptの型を指定できます。

import { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'https://hoge.com',
  documents: ['src/**/*.tsx'],
  generates: {
    './src/gql/': {
      preset: 'client',
      config: {
        scalars: {
          // Custom Scalarの`Date`型をTypeScriptの`Date`型にマッピング
          Date: 'Date',
        },
      },
    },
  },
};

export default config;

しかし、残念ながらこの対応では問題があります。

この設定で指定できるのは、あくまでGraphQL Code Generatorが生成する型のみであり、実際のフィールドの値に対しては何も変更は行われません。仮にDate型のフィールドで2023-09-01という文字列が返ってくる場合を例に考えてみます。TypeScriptの型としてはDateとして扱われますが、実際の値は文字列なので、Date型として扱おうとするとランタイムエラーが発生する場合があります。

また、Apollo Client自体の機能としてCustom Scalarのシリアライズを行えるようにして欲しいというfeature requestが2016年ごろから上がっています。次のマイナーバージョンで対応する予定とのことですが、執筆時点では未対応になります。

そこで、自分のチームではapollo-link-scalarsというApollo Linkを使用して、上記の問題に対応することにしました。

apollo-link-scalarsとは

apollo-link-scalarsはフェッチ時やリクエスト時にCustom Scalarのパースを行ってくれるApollo Linkです。任意のCustom Scalarに対して、パースの処理を指定できます。Apollo Linkで処理されるので、Apollo Clientにはパースされたデータが保存されることになります。

github.com

実装方法

今回はDate型を例に実装方法を紹介します。

まずは、Custom Scalarを判別するために必要なイントロスペクションデータをGraphQL Code Generatorで生成します。

import { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'https://hoge.com',
  documents: ['src/**/*.tsx'],
  generates: {
    './graphql.schema.json': {
      plugins: ['introspection'],
      config: { minify: true },
    },
  },
};

export default config;

生成したイントロスペクションデータとパース処理を指定して、Apollo Linkを生成します。

import { withScalars } from 'apollo-link-scalars';
import { buildClientSchema, IntrospectionQuery } from 'graphql';
import { isDate } from 'date-fns';
import introspectionResult from 'graphql.schema.json';

export const customScalarLink = withScalars({
  schema: buildClientSchema(
    introspectionResult as unknown as IntrospectionQuery,
  ),
  typesMap: {
    Date: {
      // フェッチ時のパース処理
      serialize: (parsed: unknown): string | undefined => {
        return isDate(parsed)
          ? parsed.toLocaleDateString().replaceAll('/', '-')
          : undefined;
      },
      // リクエスト時のパース処理
      parseValue: (raw: unknown): Date | undefined => {
        if (!raw) return undefined;

        // 有効な日付ではない場合は弾く
        const date = new Date(`${raw}`);
        if (!isDate(date)) {
          throw new Error(`Fail to parse date: ${raw}`);
        }

        return date;
      },
    },
  },
});

最後に生成したApollo LinkをApollo Clientの初期化時に指定すれば完了です。

import {
  ApolloClient,
  ApolloLink,
  createHttpLink,
  InMemoryCache,
} from '@apollo/client';

import { customScalarLink } from './customScalarLink';

const client = new ApolloClient({
  link: ApolloLink.from([
    customScalarLink,
    createHttpLink({ uri: 'https://hoge.com' }),
  ]),
  cache: new InMemoryCache(),
});

注意事項

apollo-link-scalarsを使うことでCustom Scalarに対してパース処理を噛ませることができるようになりますが、いくつか注意事項があります。

キャッシュとの相互作用の可能性

前述したように、定義したパース処理はApollo Linkで処理されるので、Apollo Clientのキャッシュにはパースされた値が保存されることになります。その関係でapollo-cache-persistなどを利用した際に、apollo-cache-persistのシリアライズの処理と相互に影響しあい、意図しない挙動につながる可能性があります。

バンドルサイズの肥大化

今回紹介した実装では、GraphQL Code Generatorで生成したイントロスペクションデータをそのままライブラリに渡しています。オプションでminifyをtrueにしてバンドルサイズを減らしてはいますが、それでもそれなりのサイズにはなります。プロジェクトによっては許容できない程膨れ上がる可能性もあるので、定期的にバンドルサイズをチェックして最適化を行い、場合によってはapollo-link-scalarsを使わないことも視野に入れる必要があります。

注意事項を踏まえた上での採用理由

最後に上記の注意事項が現時点では問題になっていないという前提で、apollo-link-scalarsを採用した理由を紹介します。一番の理由はパース処理をApollo Linkのレイヤーで行えるので、アプリケーションレイヤーでは単純にTypeScriptの型として扱えるという点です。仮にapollo-link-scalarsを使わずCustom ScalarのDate型を扱う場合、string型として扱うことになるかと思います。もしくはBranded TypesやTemplate Literal Typesを使ってもう少し厳格に型を定義できますが、どちらにせよ値を使う直前でDate型にパースする処理を行う必要があります。さらにDate型をリクエストに含める場合は、逆の処理を行う必要があります。該当のフィールドが少なければ問題ないですが、自分が関わっているプロダクトではこれらの処理がかなり多岐に渡る場面で必要になるので、一箇所にまとめたいという気持ちが非常に強かったです。

また、自分のチームでは全員がフルスタックに開発する文化があるので、普段はバックエンドをメインにしているメンバーがフロントエンドの開発をすることも多くあります。その際に、この辺りのパース処理に関する知識が実装者に求められる状況を避けたいという側面もあったので、Apollo Linkにパース処理を集中させて、アプリケーションレイヤーではそこを意識しなくてもいいのは非常に嬉しかったです。

まとめ

今回はapollo-link-scalarsを使用してCustom Scalarに対応する方法を紹介しました。Apollo Client自体もCustom Scalarへの対応は、リクエストも非常に多く、必要な機能だと認識しているようなので、近いうちにApollo Client自体に機能が追加されるかもしれません。もし追加された場合は、積極的に使ってみたいと思っています!

最後に、バイセルではエンジニアを随時募集しております。興味のある方はぜひ以下の採用サイトをご覧ください。

herp.careers

herp.careers