🍣

GraphQL Scalar でハッピーライフを

2023/10/21に公開

はじめに

TypeScript で GraphQL の開発をしていた時に「GraphQL は String しか扱えないから Date 型そのまま渡せなくて不便だな...。」と思っていました。
そんな過去の自分へ Scalar type と Custom Scalar を理解すると、圧倒的に GraphQL は効率的になるということを伝えるために筆を取りました。

TL;DR

GraphQL の値のバリデーション、Serialize は極力 Scalar type でやると良い。
Build-in Scalar で要件を満たさない場合は、Custom Scalar を利用する。
https://github.com/Urigo/graphql-scalars というライブラリには、汎用的な Custom Scalar が定義されていて非常に便利。

Scalar type とは

Scalar type とは GraphQL における最もプリミティブな型です。
GraphQL で出力される値は、Scalar type か Enum か null のどれかになります。

type User {
    name: String!
    email: String!
    age: Int!
}

Build-in でサポートされている Scalar type は 5 種類あります。
それぞれ

  • どんな値を入力できるか (Input Coercion)
  • どんな値を出力として持つか (Result Coercion)
  • Json として出力する際に行う値の変換方法 (Serialization)

が定義されています。
5 種類についてどのように設定されているか確認すると

Scalar type Input Coercion Result Coercion Serialization
Int 整数値 整数値とみなせる値 ("123" なども含む) Number へ変換
Float 整数値もしくは浮動小数値 浮動小数値とみなせる値 ("123" なども含む) Number へ変換
String Unicode 文字 Unicode 文字 String へ変換
Boolean Boolean (true, 1, "1" など) Boolean true もしくは false へ変換
ID 文字列もしくは整数値 ID とみなせる値 String へ変換

となります。
これらは GraphQL の仕様書に記載されているものとなり、実際のライブラリの実装は実装者に任されています。
https://spec.graphql.org/draft/#sec-Scalars

Build-in Scalar を使っているときに感じた課題

少し話が戻りますが、

「GraphQL は String しか扱えないから Date 型そのまま渡せなくて不便だな...。」

と思っていました。「不便」を分解すると、いくつかのポイントがあります。

全部 String に変換しないといけない

Build-in の Scalar type のみで表現しようとすると以下のようになってしまいます。

type User {
    archivedAt: String!
}

JavaScript の Date 型を渡したいと思ってコードを書いてもエラーになってしまいます。

// OK
return {
    archivedAt: (new Date()).toISOString()
}

// NG
return {
    archivedAt: new Date()
}

これは色々なところで toString() を書かなければならなくて、面倒です。

バリデーションを中央集権的にするのが難しい

さらに問題なのは、フロントから String を渡されることを許容してしまうことです。
本来であれば欲しいデータは日付のフォーマット化された文字列なのに、"test" みたいな文字列を許容してしまいます。

# 本当はエラーにしたいリクエスト
query {
    userByArchivedAt(gtArchivedAt: "test") {
        name
    }
}

つまりフロントエンドでバックエンドが想定していない文字列を送っても、 GraphQL の Scalar type という仕組みでは許可してしまうという問題が発生します。
この問題に対応するために、バリデーションをそれぞれの Query や Mutation で書かなければならなくなってしまいます。困りました。

Custom Scalar をどう使うか

そこで、GraphQL では Custom Scalar という仕様が用意されています。

https://spec.graphql.org/draft/#sec-Scalars.Custom-Scalars

GraphQL Schema に Custom Scalar を宣言する

Schema に以下のように自分で定義した Scalar を宣言することができます。

scalar Email @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc5322")

@specifiedBy はディレクティブと呼ばれるもので、この Scalar の定義が書かれたドキュメントのリンクを指定します。多くの GUI GraphQL クライアントで値を参照できるようになっており、Scalar はどんな値なのかを知るのに役立ちます。

Resolver を実装する

また、前述の通り Scalar は Input Coercion、 Result Coercion、 Serialization の 3 つの機能を持つ必要があります。
TypeScript の GraphQL 実装 https://github.com/graphql の場合、これらは以下のように実装することができます。

email.scalar.ts
// EmailType という Resolver を作成し、GraphQL server の resolver に追加する

const EmailType = new GraphQLScalarType({
  name: "Email",
  serialize: isEmail,
  parseValue: isEmail,
  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) {
      return isEmail(ast.value)
    }
    throw new Error("The input value is not email formated.")
  },
})

function isEmail(value) {
    if (isEmail(value)) {
        return value
    }
    throw new Error("The input value is not email formated.")
}

parseValue および parseLiteral は両方とも Input Coercion として利用されます。
ここで parseValue は、GraphQL の input variables として値が渡された時に利用されます。

input UserInput {
    email: Email!
}
type Query {
    userByEmail(variables: UserInput!): User!
}

一方で parseLiteral は、literal values として値が渡された時に利用されます。

type Query {
    userByEmail(email: Email!): User!
}

そして serialize は Result Coercion、 Serialization の両方として利用されます。

Resolver を実装する (図解)

図にまとめると以下のようになります。

GraphQL Scalar で始まる幸せな開発体験

このように Custom Scalar を使うことでサーバーへのリクエストのバリデーションや、出力の際のバリデーション & 値の変換も行うことができるようになります。

ライブラリを導入する

よく使う Custom Scalar をまとめたレポジトリはいくつかあります。

graphql グループ配下の https://github.com/graphql/graphql-scalars や、The Guild がメンテナンスをしている https://github.com/Urigo/graphql-scalars などです。

こうしたライブラリを利用することで簡単に GraphQL Scalar を導入できます。
サンプルで実装してみたのでよければ参照ください。

https://github.com/motoya-k/lesson-graphql-scalar/blob/main/src/graphql/scalars.ts

導入前は

type Tenant {
    id: ID!
    name: String!
    latitude: Float
    longitude: Float
    page: String
    createdAt: String!
    updatedAt: String!
}

こんなスキーマだったのが、

type Tenant {
    id: ID!
    name: String!
    latitude: Latitude
    longitude: Longitude
    page: URL
    createdAt: DateTimeISO!
    updatedAt: DateTimeISO!
}

こんなに厳密になりました! ハッピー😊

実際に効果を試してみる

実際にサーバーにリクエストを送ってみます。(https://graphql-scalar-sample.deno.dev/graphql で確認できます)
正しい値で Mutation をリクエストするとうまくいきますが

https://graphql-scalar-sample.deno.dev/graphql?query=mutation+{ ++createTenant(input%3A+{ ++++name%3A+"custom+scalar+test" ++++latitude%3A+"1" ++++longitude%3A+"2" ++++page%3A+"https%3A%2F%2Fexample.com" ++})+{ ++++id ++++name ++++latitude ++++longitude ++} }

例えば URL 型がついているところに不正な文字を入れるとエラーが返ってくるのがわかります。

https://graphql-scalar-sample.deno.dev/graphql?query=mutation+{ ++createTenant(input%3A+{ ++++name%3A+"custom+scalar+test" ++++latitude%3A+"1" ++++longitude%3A+"2" ++++page%3A+"invalid" ++})+{ ++++id ++++name ++++latitude ++++longitude ++} }

まとめ

GraphQL の値のバリデーション、Serialize は極力 Scalar type でやるのが良いです。
その際に Build-in Scalar で要件を満たさない場合は、Custom Scalar を利用することができます。
また、https://github.com/Urigo/graphql-scalars というライブラリには、汎用的な Custom Scalar が定義されていて非常に便利です。

(ちなみに、Custom Scalar を利用することで Codegen の際にも狙った型をつけることができ開発体験が爆上がりしました。その話はまた別の機会にまとめます。)

Discussion