- バックエンド / リーダー候補
- PdM
- Webエンジニア(シニア)
- Other occupations (17)
- Development
- Business
graphql-codegen と Nominal Typing(Branded Type) で Custom Scalar をちょっといい感じにする
Photo by Chien Nguyen Minh on Unsplash
GraphQL では標準で用意されてる Int
や String
以外にも、自前で 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 のつもりで birthday
を Date
にしていますが、型が 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 にする
scalars
で string
ではなく別の型を指定するのはどうでしょう。ここで any
や string
ではなく 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
※ Types
は import-types や near-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 をうまく使えばスキーマの表現力がかなり高くなるので、活用していきたいですね。