1
/
5

graphql-codegen と Nominal Typing(Branded Type) で Custom Scalar をちょっといい感じにする

GraphQL では標準で用意されてる IntString 以外にも、自前で Scalar を定義することができます(ここでは Custom Scalar と呼びます)。よくある用途としては日付を表す Date や日時を表す DateTime でしょうか。

type Profile {
  birthday: Date
}

scalar Date

Custom Scalar をうまく使えば GraphQL スキーマでドメインを表現する力がかなり高くなるので、みんなで育てる共通言語としてはどんどん活用していきたいです。

一方で、Apollo CLI や GraphQL Code Generator(以下 graphql-codegen)で Custom Scalar を使うスキーマから TypeScript のコードを生成すると、悲しいことになりがちです。

上の例は Date custom scalar のつもりで birthdayDate にしていますが、型が any になっています。不安ですね。この不安を取り除こうという話です。

アイディア1: string にする

@graphql-codegen/typescript@graphql-codegen/typescript-operations などの plugin には scalars というオプションがあり、Custom Scalar を TypeScript 上ではどんな型にするかを設定できます。any は最も良くないので、とりあえず実態に即した型として string にしてあげると良さそうです。

generates:
  src/path/to/graphqlTypes.ts:
    config:
      # なんか便利なオプション色々...
      scalars:
        Date: string

ただし、string だと他の任意の文字列と区別することはできません。たとえば名前と誕生日は同じ型になるでしょう。誕生日は Date custom scalar を利用していて、(サーバ側でちゃんと実装していれば)日付文字列 YYYY-MM-DD であることは保証できるはずです。なるべくその他の文字列とは区別しておくほうが、バグやデグレを生みにくいでしょう。

アイディア2: unknown にする

scalarsstring ではなく別の型を指定するのはどうでしょう。ここで anystring ではなく unknown にマッピングしてみます。unknown であれば利用時に型の明示を要求できるため、少なくとも「これは普通の string ではないんだな」と開発者に認識させることができます。これで状況は多少マシになるでしょう。

TS Playground

とはいえ型情報が落ちてることには変わりなく、TypeScript のコード中では birthday が日付文字列 YYYY-MM-DD であることの保証はどこにもありません。「この birthday というフィールドは GraphQL クエリ結果から来た日付文字列である」というのが型として表現されているというのがわかるとより良さそうです。

アイディア3: Branded Type で Nominal Typing する

TypeScript 自体は Structural Typing に基づいているので、構造が同じ2つの型は同じ型とみなします。今回の例での要求は「GraphQL の Custom Scalar は JSON 上では文字列だが、型上で区別したい」でした。素直に書くと以下のようになりますが、これでは要求は実現されません。

type DateString = string;

function parseDateString(dateStr: DateString) {
  // ...
}

parseDateString("ぴよぴよ");  // 日付文字列を他の文字列と区別したいのに、できてない
TS Playground

ここで出てくるのが Nominal Typing です。TypeScript Deep Dive の Nominal Typing の章のリード文を見ると、まさに今欲しいものであることがわかります。

However, there are real-world use cases for a system where you want two variables to be differentiated because they have a different type name even if they have the same structure.

この Nominal Typing を TypeScript でエミュレートするために、Branded Type という手法を利用します。TypeScript のコンパイラ実装内でも利用されているので、なんとなく信頼できそうですね(最近だと OKUNOKENTARO さんが Harajuku.ts Meetup の発表で紹介されていました)。

Branded Type で DateString を定義し graphql-codegen で利用することができれば、今回の困りは解決されそうです。

type DateString = string & { __dateStringBrand: any  };

function parseDateString(dateStr: DateString) {
  // ...
}

// これはエラーとなり、日付文字列を他の文字列と区別できていることがわかる
const date = parseDateString("ぴよぴよ");
TS Playground

YYYY-MM-DD 文字列であれば Template Literal Types で表現できそうな気もしますが、それはこの記事のテーマから逸れるので脇においておきます。例が悪かった。)

これで「この birthday というフィールドは日付文字列である」ということは宣言できるようになりました。今回は日付文字列を例にしましたが、任意の Custom Scalar で応用できるはずです。

あとは、 GraphQL のレスポンスに自前の型を利用できれば良さそうです。

自前の型を graphql-codegen で利用する

graphql-codegen には @graphql-codegen/add という生成ファイルに任意の文字列を追加できる便利プラグインがあり、これを利用すれば任意の型定義を忍び込ませることができます。

generates:
  src/path/to/graphqlTypes.ts:
    plugins:
      - add:
          content: '// Code generated by graphql-codegen. DO NOT EDIT.'
      - add:
          content: 'export type DateString = string & { __dateStringBrand: any };'
      - typescript
    config:
      # なんか便利なオプション色々...
      scalars:
        Date: DateString

上記は @graphql-codegen/typescript の設定例ですが、@graphql-codegen/typescript-operations を別のファイルに吐いている場合は @graphql-codegen/typescript 側に定義された DateString を import してくる必要があり、ちょっとだけ工夫が必要になります。

generates:
  # ...

  src/path/to/graphqlOperationTypes.ts:
    plugins:
      - add:
          content: '// Code generated by graphql-codegen. DO NOT EDIT.'
      - typescript-operations
    config:
      # なんか便利なオプション色々...
      scalars:
        # ちょっと hacky な感じがして不安ではあるが…。
        Date: Types.DateString 

Typesimport-typesnear-operation-file などの preset の importTypesNamespace の初期値。@graphql-codegen/typescript の出力がこの名前で import されるため、Types.YourType で自前で定義した型を参照できる。

上記の設定を入れることで、graphql-codegen の生成結果で Nominal Typing な DateString が利用されるようになります。これで「この birthday というフィールドは GraphQL クエリ結果から来た日付文字列である」というのが型として表現されている という要求が実現できました。

アイディア0: GraphQL クライアントが Date にシリアライズしてくれ

今回の例だと、そもそも文字列じゃなくて Date object を返してくれたらそれでいいという話もあるかもしれません。しかし、型を Date にするにはデータ自体もDate にシリアライズする必要があります。これは変換漏れをふせぐためにも Apollo Client など GraphQL クライアント側でやることになるでしょう。

たとえば Apollo Client だとキャッシュから出し入れするタイミングで Date に変換するということが可能です。が、キャッシュ操作にフックすることになるので no-cache だと意図した挙動になりません。やるなら no-cache を ESLint 等で警告することがセットになりそうです。

余談ですが、Apollo Kotlin だと Scalar のマッピング先の class を設定できて、かつシリアライズ・デシリアライズもいい感じにしてくれるらしいですね。JS でもいい方法発明したい。

まとめ

graphql-codegen + typescript で Custom Scalar を扱いたいときのプラクティス:

  • 最低限、any ではなく unknown にマッピングしましょう
  • @graphql-codegen/add を使って Branded Type を利用した型定義を差し込むことで、その Custom Scalar 専用の型を定義することが可能

Custom Scalar をうまく使えばスキーマの表現力がかなり高くなるので、活用していきたいですね。

Wantedly, Inc.'s job postings
7 Likes
7 Likes

Weekly ranking

Show other rankings
Invitation from Wantedly, Inc.
If this story triggered your interest, have a chat with the team?