Header image

クラッソーネの開発者がエンジニアリングに関することもそうでないことも綴っています!

TypeScriptとcodegenで型安全なGraphQLライフを!

TypeScriptとcodegenで型安全なGraphQLライフを!

こんにちは、クラッソーネの深町です。
春に仕込んだいちごウィスキーがいい感じに仕上がってきました。今年こそは梅酒にも挑戦しようと思っています。
去年はコロナのゴタゴタですっかり忘れてしまっていたのですが、今年は 10 種類以上のスピリッツを用意して準備万端です!

さてさて、クラッソーネでは現在、新しいプロダクトを開発中です。
できるだけモダンな技術を活用してみようということで、React と GraphQL を使って開発を進めております。

この記事では、GraphQL の fragment と自動生成された TypeScript の型を使って安全かつ便利に GraphQL アプリケーションを構築する方法をご紹介します。

fragment と型

GraphQL の fragment は、クエリのレスポンスに含めてほしいフィールドの集合を「再利用可能な部品」として指定できる機能です。
「あるコンポーネントに関連するものは同じ場所に配置する」という colocation の考え方とも相性がよいですね。

コンポーネントで参照したいフィールドだけを fragment に含め、他のフィールドはたとえ存在しても参照しないようにする、というのが基本戦略です。

でも、fragment を使用したとしても、 JavaScript ではどんなフィールドにでもアクセス(しようと)することができてしまいます。

そこで、TypeScript の登場です!

TypeScript の静的型付けと fragment を組み合わせると、アクセスできるフィールドを厳密に限定することができます。

TypeScript を使うことの利点はビルド時に多くのエラーを拾えること。
静的型付けを賢く使うことによって実行時エラーを最小限に抑えることができます。

また、graphql-codegen を使うと GraphQL スキーマから TypeScript の型を自動生成できます。
codegen のおかげで、同じような型をいろんなところで定義しなくてはならなくなるという問題も解消します。

それでは、TypeScript と codegen を活用して、安全に GraphQL のコーディングを楽しみましょう!

使うもの

サンプルアプリケーション

次のような、「Todo」と「Appointment」を予定一覧として表示する単純なアプリケーションを考えてみます。
今回は表示だけで、 mutation は扱いません。

サンプルアプリのイメージ

GitHub リポジトリ

https://github.com/kei-f/react-graphql-typescript-example

自分で最初からセットアップする場合は以下を参考にしてください。

セットアップ

  1. Create React App (TypeScript)
npx create-react-app YOUR_APP_NAME --template typescript
cd YOUR_APP_NAME
  1. Dependencies
npm install graphql @apollo/client
npm install -D @graphql-codegen/cli
# サーバも Apollo で作る場合
npm install apollo-server
  1. graphql-codegen
npx graphql-codegen init

? What type of application are you building?
 ◯ Backend - API or server
 ◯ Application built with Angular
❯◉ Application built with React
 ◯ Application built with Stencil
 ◯ Application built with other framework or vanilla JS

# apollo-serverのデフォルト。出力されたスキーマファイルへのパスでもよい
? Where is your schema?: (path or url) http://localhost:4000/

? Where are your operations and fragments?: src/**/*.graphql

# Introspection Fragment Matcherを選択する(デフォルトで選択されていない)
? Pick plugins:
 ◉ TypeScript (required by other typescript plugins)
 ◉ TypeScript Operations (operations and fragments)
 ◉ TypeScript React Apollo (typed components and HOCs)
 ◯ TypeScript GraphQL files modules (declarations for .graphql files)
 ◯ TypeScript GraphQL document nodes (embedded GraphQL document)
❯◉ Introspection Fragment Matcher (for Apollo Client)
 ◯ Urql Introspection (for Urql Client)

? Where to write the output: src/generated/graphql.tsx
? Do you want to generate an introspection file? Yes
? How to name the config file? codegen.yml
? What script in package.json should run the codegen? graphql
  1. Apollo client
// index.tsx
// ... (import文 省略)
import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client";
import graphql from "generated/graphql";

const client = new ApolloClient({
  uri: "http://localhost:4000",
  cache: new InMemoryCache({
    // possibleTypesはFragment Matcherプラグインで生成される
    // これを渡しておかないとunionやinterfaceのfragmentをうまく扱えない
    // https://www.apollographql.com/docs/react/data/fragments/#defining-possibletypes-manually
    possibleTypes: graphql.possibleTypes,
  }),
});

ReactDOM.render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </React.StrictMode>,
  document.getElementById("root")
);

GraphQL スキーマ

type Todo {
  id: ID!
  title: String!
  description: String!
  deadline: String!
  tags: [Tag!]!
  done: Boolean!
}

type Appointment {
  id: ID!
  title: String!
  place: String!
  datetime: String!
  tags: [Tag!]!
}

union ScheduleItem = Todo | Appointment

type Tag {
  id: ID!
  label: String!
  color: String!
}

type Query {
  schedule: [ScheduleItem!]!
}

基本的な開発の流れ

  1. コンポーネントを設計し、そのコンポーネントに必要なフィールドを洗い出す
  2. 対応する fragment を書く
  3. codegen で型を生成する
  4. その型を使用してコンポーネントを実装する

例えばTodoを表示するコンポーネント<TodoItem>に対応する fragment は次のようになります。

# TodoItem.fragment.graphql

fragment TodoItem on Todo {
  id
  title
  description
  deadline
}

このファイルを作って codegen を実行すると、TodoItemFragmentという型が生成されます。
この型を使って<TodoItem>コンポーネントを実装します。

// TodoItem.tsx

import { TodoItemFragment } from "generated/graphql";

type Props = {
  todo: TodoItemFragment;
};

const TodoItem = ({ todo }: Props) => {
  const { title, description, deadline } = todo;

  // ... 省略
};

そして、このコンポーネントを使う側では、それ自身の fragment や query の中で、子として含むコンポーネントの fragment を展開します。

# ScheduleItem.fragment.graphql

fragment ScheduleItem on ScheduleItem {
  ... on Todo {
    ...TodoItem
    tags {
      ...Tag
    }
  }
  ... on Appointment {
    ...AppointmentItem
    tags {
      ...Tag
    }
  }
}

この例のように、union や interface の fragment を含む場合は Apollo client のキャッシュにpossibleTypesを設定しておく必要があります。(「セットアップ」の 4.参照)

possibleTypesの中身は{ ScheduleItem: ["Todo", "Appointment"] }のようなオブジェクトです。
手書きしても動きますが、スキーマは将来にわたって変更される可能性が常にあるので、codegen に生成してもらうのが無難です。

サーバからのレスポンスには正しく値が入っているのにコードから見えないという場合は、この設定を忘れている可能性があります。

こんな感じでコンポーネントごとに fragment を定義していけば、アプリケーションが複雑になってもメンテナンス性の高いコードベースを維持できるのではないかと思います。

GraphQL type に対応する型について

codegen は fragment だけでなく、GraphQL の type にそのまま対応する型も生成してくれます。
このサンプルの例ではTodo, Appointment, Tagのような型です。

fragment を使わずにこれらをそのまま使いたくなる気持ちもわかりますが、それは危険です。

なぜならこれらの型を使うと実際のクエリで取得しているかどうかにかかわらず、常に全てのフィールドにアクセスできてしまうからです。

また、これらの型を使おうと思うとどこかでasによるアサーションを使用することになります。

TypeScript のasはアサートしているだけで、型の変換を伴いません。
簡単に言うと、「コンパイラの推論より俺の方が正しい。おまえ(コンパイラ)は黙れ」という意味です。
もちろんそれが正しい場合(API のレスポンスなど、コンパイラが知りようもない情報をもとにアサートする場合など)もありますが、基本的には危険だと思ったほうがいいでしょう。

id の扱いについて

このサンプルでは、<TodoItem>などのコンポーネントでは使用していないにもかかわらず、idフィールドを fragment に含めています。

そして、一段上の階層でリスト表示するときに、keyとしてidを使っています。
厳密にはidを利用するのはリスト表示する側のコンポーネントなのでそちらのコンポーネントに対応する fragment をちゃんと書くべきなのですが、それはちょっと冗長な気がします。

このように一覧表示系のコンポーネントでkeyとしてidを渡したいことはよくありますので、子コンポーネントで使っても使わなくてもidは含めておいてもよいのではないでしょうか。

また、idを使わなくてもクエリに含めておいた方がよい理由として、Apollo のキャッシュがあります。
Apollo のキャッシュでは、オブジェクトをキャッシュする時のキーとして暗黙的にid (または _id)という名前のフィールドを利用します。
idフィールドを含めていないとオブジェクト単位のキャッシュが作られません。

ですので、id を持つオブジェクトはコンポーネントで使用するかどうかにかかわらず、とりあえず含めておくようにするのがいいのではないかと思います。

fragment の命名規則について

codegen を使用するときの問題点のひとつとして、fragment や query などの名前はコードベース全体でユニークである必要がある、というのがあります。

このサンプルアプリは小さいので特に問題になりませんが、アプリの規模が大きくなってくると結構悩ましい問題です。

fragment の命名に関しては、Relay の規則(<FileName>_<propName>)を参考にするとよいと思います。

// TodoItem.tsx
const TodoItem = ({ todo }: Props) => {

// -> Todoに関するfragment名は TodoItem_todo

同じオブジェクトでも「場所によって必要なフィールドが違う」というのが fragment を使う主な理由なので、「『どこ』で使う『何』なのか」という情報を含むこの名前の付け方は理にかなっていると思います。

おわりに

GraphQL fragment と TypeScript を組み合わせるアプリケーションの構築例をご紹介しました。
Code generator を使用することで、(名前の衝突にさえ気をつければ)fragment ごとに厳密な型を得ることができます。
安全でメンテナンス性の高いコードベースを保つ一助になれば幸いです。

クラッソーネでは安全なコーディングライフをエンジョイしたいエンジニアを大募集しています。
もし興味を持っていただけたら、お気軽にご連絡いただけると嬉しいです!

https://www.crassone.co.jp/recruit/engineer/