⛑️

TypeScriptとGraphQLで実現する型安全なAPI実装

2024/05/11に公開

この記事はTSKaigi2024での以下の私の発表内容を書き下ろしたものです。

https://speakerdeck.com/hokaccha/tskaigi-2024

なぜAPIに型をつけたいのか

現代のWebのシステム開発において、クライアント・サーバーともに型のある言語で開発されることが増えてきました。静的な型検査はコードの堅牢性やよりよいメンテナンス性の向上をもたらします。

プログラミング内部だけで型検査をするだけでも十分メリットはありますが、外部I/Oに対する型付けが不十分だとそのメリットを最大限に発揮してるとは言えません。外部I/Oとは、例えばWebフロントエンドだとLocalStorageやDOMからの入力値、それからネットワーク通信(今回はこれをAPIと呼びます[1])などですね。サーバー側でいうとAPIからの入力・レスポンスやデータベースへの読み書きが該当します。

個人的な経験から言うと、Webシステムの開発におけるエラーの多くはAPIやデータベースとのやり取りにおけるデータ不整合によるもので、これらが型安全になることで多くのバグを未然に防げると思っています。

データベースの読み書きに対して型をつけるというのもおもしろいトピックではありますが、今回はAPIに焦点を当てて見ていきましょう。典型的な例としては、存在していると思っていたフィールドが存在していなかった、何かしら値が返ってくる前提でコードを書いていたが null が返ってきた、などですね。

type User = {
  id: string;
  name: string;
}

const response = await fetch(`/users/${id}`);
const user: User = await response.json();
console.log(user.name.toString());

例えばこのようなコードで、 response.json() の型は any になりますが、User にキャストしています。このときAPIのレスポンスでnamenullだった場合、プログラムは実行時にエラーになる可能性があります。これを解決するために以下のいずれかが必要になります。

  • nameはNullableがAPIの仕様であればクライアント側の型が間違っているのでクライアント側を直す
  • nameはNonNullがAPIの仕様であればサーバー側のバグなのでサーバー側を直す。

しかし、仕様の確認やそれに合わせた型定義を人の手で毎回やるのは面倒すぎますし、必ずミスが発生します。これを解決するアプローチとして、APIの仕様を型のあるスキーマとして定義し、それを元に型や実装を自動生成することでAPIの呼び出しやサーバーの実装をより堅牢にするというのが今回の趣旨です。

APIに型をつけるとは

今回、「APIに型をつける」とは以下のような内容として扱います。

  1. APIの仕様をスキーマで定義する
  2. スキーマから実装言語の型を生成する
  3. 生成された型を使って実装する
    • クライアントの実装
    • サーバーの実装

まずはAPIの仕様を型のあるスキーマで定義し、そのスキーマをつかってクライアント・サーバーの実装に対して型をつけます。このようなことを実現する技術としては色々ありますが、メジャーなところだと以下のようなものがあります。

  • REST + OpenAPI
  • gRPC
  • GraphQL

今回はGraphQLについて紹介する前提なのでこれらの詳細な比較検討はしませんが、型安全性に着目するとgRPCのアプローチが筋がいいと個人的には思います。gRPCはProtocol Buffersから実装も含めて自動生成するので、デフォルトで型付きの実装が手に入ります。

それに比べるとGraphQLは今回紹介するような、オプトインで型をつける必要があるので型安全にするための強制力は弱くなりますが、クライアントから柔軟なクエリができるなどのメリットがあります。gRPCやGraphQL、RESTの比較は型安全性だけで語れるわけではありませんが、一つの比較要素にはなると思います。

例えばGraphQLでは以下のようなスキーマを定義します。

schema.graphql
type Query {
  user(id: Int!): User!
}

type User {
  id: Int!
  name: String!
}

これに対して以下のような型を生成します。

generated.ts
type Query = {
  user: (id: number) => Promise<User>;
};

type User = {
  id: string;
  name: string;
};

これを次のように利用します。なお、これは話をわかりやすくするために極限までシンプルにした例なので実際は後述するようにもう少し複雑です。

client.ts
const fetchUser: Query["user"] = async (id) => {
  const { user } = await request(`
    query getUser {
      id
      name
    }
  `);
  return user;
};
server.ts
const userResolver: Query["user"] = async (id) => {
  const user = await db.users.find(id);
  return user;
};

イメージとしてはこのような感じです。TypeScriptとGraphQLにおいてスキーマから型を生成するツールとしてデファクトになっているのがGraphQL Codegenというツールです。

https://the-guild.dev/graphql/codegen

GraphQL Codegenを使うと簡単に型の生成ができますが、クライアント、サーバーそれぞれで考えることやベストプラクティスは異なるので、それぞれ詳細に見ていきましょう。

クライアントサイドのプラクティス

クライアントサイドでは、公式が推奨しているClient Presetを使うのが現状ではベストプラクティスといえます。

https://the-guild.dev/graphql/codegen/plugins/presets/preset-client

これをインストールし、次のような設定を書いたら準備は完了です。

$ npm install @graphql-codegen/cli @graphql-codegen/client-preset
codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  generates: {
    "./src/generated/": {
      schema: "./schema.graphql",
      documents: ["./src/**/*.{ts,tsx}"],
      preset: "client",
    },
  },
};
export default config;

以下のコマンドで型を生成できます。

$ npx graphql-codegen

クエリから作った型を使う

まず、重要な考えとしてクライアントはGraphQLのスキーマからだけでなく、発行するクエリを元に型をつける必要があるということです。例えば次のようなGraphQLのスキーマがあったとします。

schema.graphql
type User {
  id: ID!
  name: String!
  imageUrl: String!
}

ここから次のような型が生成されます。

generated.ts
type User = {
  __typename?: 'User';
  id: string;
  name: string;
  imageUrl: string;
};

これを使ってリクエストに型をつけてみます。

client.ts
const user: User = await request(`
  query getUser {
    id
    name
  }
`);

この実装の問題点は、実際のリクエストに含まれていない imageUrlUser 型に含まれることです。これでは型安全な実装とはいえません。そこで、スキーマから生成した型でなくクエリから生成した型を使います。次のようになります。

client.ts
import { graphql } from "./generated/gql";
import { GraphQLClient } from "graphql-request";
import { FetchUserQuery } from "./generated/graphql";

const client = new GraphQLClient("http://localhost:8000/graphql");

// GraphQL Codegenがコード中のクエリ文字列を元にTypedDocumetNodeを自動生成する
const fetchUserDocument = graphql(`
  query fetchUser($id: ID!) {
    user(id: $id) {
      id
      name
    }
  }
`);

async function fetchUser(id: string): Promise<FetchUserQuery["user"]> {
  // graphql-requestがTypedDocumetNodeに対応しているのでレスポンスに型がつく
  const { user } = await client.request(fetchUserDocument, { id });
  return user;
}

GraphQL Codegenはコードなに書かれたGraphQLのクエリを検出し、そこから型を生成します。その型を含んだクエリのASTを graphql 関数が返します。そのASTが上記コードの fetchUserDocument で、この型は TypedDocumentNode という名前の型です。

以下のようなメジャーなGraphQLのクライアントライブラリは TypedDocumentNode に対応しているため、GraphQL Codegenの型付けの機能はクライアントライブラリに左右されず自由に選ぶことができます。

Fragment Colocation

Client Presetのもうひとつの大きな機能に、Fragment Maskingというものがあります。Fragment Maskingを理解する前にまずはFragment Colocationについて知っている必要があるのでこちらから説明していきます。

Fragment ColocationとはGraphQLのクエリをViewコンポーネントが必要な単位ごとにFragmentに分割しそのFragment定義をコンポーネントと同じ場所に配置するという手法のことです。例えば、以下のようにルートコンポーネント(UserPage)で発行したクエリの結果を末端の UserProfile コンポーネントが受け取る例を見てみます。

このとき、UserProfile で、 imageUrl を使わないような変更を行ったとしましょう。そうするとUserProfile の Props から imageUrl が消えますが、大元のクエリの imageUrl のフィールドを消し忘れても型エラーにはなりません。どこか他のコンポーネントで利用されているかもしれないし、実際にリクエストしたフィールドが使われているかどうかの精査までは関与しないからです(別途lintなどのツールを使えば可能だとは思います)。

また、この UserProfile が複数のページから再利用されていた場合、それらのページで発行するすべてのクエリを修正する必要が発生します。

この設計の歪としては、末端のコンポーネントも含めた子が必要としているフィールドをルートのコンポーネントが全て知っている必要があるというところにあります。そこでGraphQLのFragmentの機能を使い、以下のようにクエリを分割します。

コンポーネントのコードは次のようになります。

UserProfile.tsx
import { graphql } from "./generated/gql";
import { UserProfileFragment } from "./generated";

// このFragment定義からGraphQL Codegenが型を自動生成する
graphql(`
  fragment UserProfile on User {
    name
    imageUrl
  }
`);

type Props = {
  user: UserProfileFragment;
};

export const UserProfile: FC<Props> = ({ user }) => {
  return (
    <div>
      <div>{user.name}</div>
      <img src={user.imageUrl} alt={user.name} />
    </div>
  );
};

このように、コンポーネントごとにFragmentを作り、Fragmentの定義をコンポーネントと同じ場所に定義する手法がFragment Colocationです。

Fragment Masking

これでも十分実用的ではあるのですが、一点問題になるのは、特定のコンポーネントのFragmentで定義したフィールドが他のコンポーネントでも利用できてしまうということです。

以下の例では、UserProfile で定義したFragmentの情報を OtherComponent が利用しています。これが許容されるとUserProfileが自身のFragment定義を変更したときに他のコンポーネントに影響がでる可能性があるため、コンポーネントの独立性が低くなり、保守性の低下につながります。

これを解決するための仕組みがFragment Maskingです。Fragment Maskingは自動生成する型を単純なオブジェクトでなく、特別な関数を通さないと利用できないにすることでFragment Colocationを強制する仕組みです。

GraphQL CodegenのClient PresetではデフォルトでFragment Maskingは有効[2]なので、前述のようなコードは型エラーになります。

このように、直接Fragmentを利用したクエリの型を利用できなくなっています。型チェックを通すにはコンポーネント側を次のようにします。

UserProfile.tsx
import { graphql } from "./generated/gql";
import { FragmentType, useFragment } from "./generated";

const UserProfileFragment = graphql(`
  fragment UserProfileFragment on User {
    name
    imageUrl
  }
`);

type Props = {
  // ここの型をFragmentTypeから作る
  user: FragmentType<typeof UserProfileFragment>;
};

export const UserProfile: FC<Props> = ({ user }) => {
  // useFragmentを通さないとFragmentで定義したデータを参照できない型になる
  const fragment = useFragment(UserProfileFragment, user);
  return <div>
    <div>{fragment.name}</div>
    <img src={fragment.imageUrl} alt={fragment.name} />
  </div>;
};

user の型を FragmentType<typeof UserProfileFragment> にしたうえで、useFragment 関数を使ってFragmentの型を利用可能な型にしています。useFragment 関数はそれ自体は単に入力された引数(ここではuser)をそのまま返すだけの関数なので、Fragmentの型をほどく以外にランタイムの動作に影響は与えません。

この Fragment Masking の機能を利用することで Fragment で指定したフィールドをそのまま利用することができなくなるので Fragment Colocation が強制されます。デメリットとしては型定義が複雑になるため型が合わないときのデバッグが難しくなるというものがあります。個人的にはこのデメリットも無視できないと思っているので、導入するかどうかはそのトレードオフも含めて検討するとよいでしょう。

サーバーサイドのプラクティス

次にサーバーサイドを見ていきましょう。

GraphQL Resolvers plugin

JavaScriptでのGraphQLのサーバー実装には Apollo ServerGraphQL Yoga などがあります。それらのパッケージの内部では公式のリファレンス実装である graphql-js が利用されていて、インターフェースが統一されているので GraphQL Codegen の Graphql Resolvers plugin を使うことでほとんどのフレームワークに対応することができます。

https://the-guild.dev/graphql/codegen/plugins/typescript/typescript-resolvers

ただ、NestJSだけ少し特殊な事情があるので以下を参照してください。

https://zenn.dev/ubie_dev/articles/c752845558e2b5

Client Preset と同じように Server Preset というのもあるにはあるのですが、わりと癖が強めなので個人的には Resolvers plugin を使いつつ用途ごとに設定でカスタマイズするくらいがちょうどよいと思います。

ベースの設定はこのようになります。

codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  generates: {
    "./generated.ts": {
      schema: "./schema.graphql",
      plugins: ["typescript", "typescript-resolvers"],
      config: {
        // Apollo Serverの場合に必要な設定
        useIndexSignature: true,
        // サンプルコードでアプリケーション側で使う型と区別するためにprefixをつける
        typesPrefix: "Gql",
      },
    },
  },
};
export default config;

Resolverの実装

Resolver側のスキーマと実装はクライアント側とは少し変えて以下のようにします。

schema.graphql
type Query {
  user(id: ID!): User!
}

type User {
  id: ID!
  name: String!
  company: Company!
}

type Company {
  id: ID!
  name: String!
}
model.ts
// データベースのレコードに対する型
type User = {
  id: string;
  name: string;
  companyId: string;
};

type Company = {
  id: string;
  name: string;
};

// データベースからレコードを探して返す関数
export async function fundUser(id: string): Promise<User> {
  return db.user.find(id);
}

export async function fundCompany(id: string): Promise<Company> {
  return db.company.find(id);
}
resolver.ts
const resolvers = {
  Query: {
    user: (_, args:): User => {
      const user = await findUser(args.id);
      const author = await findCompany(user.companyId);
      return {
        ...user,
        company,
      };
    },
  },
};

ここでいうmodelというのはデータベースのデータに型をつけたものと考えてください。ResolverというのがGraphQLサーバーの実装の肝の部分で、一般的なWebフレームワークでいうところのControllerにあたります。これに対してスキーマから生成した型を付与すると以下のようになります。

resolver.ts
import { GqlResolvers, GqlUser } from "./generated";
import { findUser, findCompany } from "./model";

// GqlResolvers が生成された型
export const resolvers: GqlResolvers = {
  Query: {
    user: async (_, args: { id: string }): Promise<GqlUser> => {
      const user = await findUser(args.id);
      const author = await findCompany(user.companyId);
      return {
        ...user,
        company,
      };
    },
  },
};

基本的には resolvers に対して GqlResolvers の型をつけるだけです。引数の型(args)や返り値の型(GqlUser)もわかりやすさのために書いていますが、推論されるので明示的に書かなくても大丈夫です。

ネストしたフィールドのResolver定義

ではサーバーサイドはこれで問題なく終わりかというとそう簡単ではありません。例えば上記のResolver実装に対して以下のクエリを実行したとします。

query fetchUser($id: ID!) {
  user(id: $id) {
    id
    name
  }
}

このクエリにはcompanyが指定されていません。しかしResolverの実装はフィールドの指定の有無に関わらず findCompany が実行される。多くのアプリケーションではこういった処理はDBや別のマイクロサービスに問い合わせると思いますが、そういったネットワークI/Oは軽い処理とはいえず、可能な限り無駄に実行したくありません。そこで、Resolverの実装を以下のようにします。

resolver.ts
export const resolvers: GqlResolvers = {
  Query: {
    user: (_, args: { id: string }): Promise<GqlUser> => {
      return findUser(args.id);
    },
  },
  User: {
    company: (user: GqlUser): Promise<GqlCompany> => {
      return findCompany(user.companyId);
    },
  },
};

User.companyはクエリでcompanyが指定されたときだけ実行されます。これはRESTやRPCのサーバー実装に慣れていると直感的にはわかりづらいですが、クライアントが自由にフィールドを指定でき、指定されたフィールドに応じて処理を実行したいといういうGraphQLサーバーの要求にあった設計になっていると言えます。

mappersの設定

しかし、上記の実装は型エラーになります。

これはQuery.userが返す型がGqlUserになっているのに対してfindUserが返す型がmodelで定義したUserだからです。

model.ts
type User = {
  id: string;
  name: string;
  companyId: string;
};
generated.ts
type GqlUser = {
  id: string;
  name: string;
  company: Company;
};

Query.userでは無駄な処理が実行されないようにcompanyを返さないように変更したのでcompanyがないのは当然ですね。一方でcompanyIdGqlUserにはないので以下も型エラーになります。

  User: {
    company: (user: GqlUser): Promise<GqlCompany> => {
      // ここも型エラー
      return findCompany(user.companyId);
    },
  },

これを解決するために、GraphQL Codegenのmappersという設定でResolverが扱う型を指定します。

codegen.ts
const config: CodegenConfig = {
  generates: {
    "./generated.ts": {
      schema: "./schema.graphql",
      plugins: ["typescript", "typescript-resolvers"],
      config: {
        useIndexSignature: true,
        typesPrefix: "Gql",
        // これを指定する
        mappers: {
          User: "./model#User",
          Company: "./model#Company",
        },
      },
    },
  },
};

こうすることで、Resolverが扱う型がmodelの型になり、Resolverの実装は以下のようになります。

resolver.ts
import { GqlResolvers } from "./generated";
import { findCompany, findUser, User, Company } from "./model";

export const resolvers: GqlResolvers = {
  Query: {
    // 返す型がスキーマから生成したものでなくmodelの型になっている
    user: (_, args: { id: string }): Promise<User> => {
      return findUser(args.id);
    },
  },
  User: {
    company: (user: User): Promise<Company> => {
      return findCompany(user.companyId);
    },
  },
};

これで無事型チェックは通ります。Resolverを実装しているとほぼ全てのケースでmapperを書くことになりますし、もう少しいい方法はないものかと思っていますが今のところは愚直に書くしかなさそうです。

まとめ

GraphQLのAPIに型をつけるメリットやGraphQL Codegenを使ったクライアント・サーバーのプラクティスについて説明しました。GraphQLとTypeScriptを使った型安全な開発環境はコードの信頼性と生産性の向上につながります。型をつけるための労力は小さくありませんが、そのメリットは非常に大きく、労力をかける価値はあると信じています。

もしGraphQLとTypeScriptを使っていて、GraphQL Codegenを使った型生成を導入していなかったら、ぜひこれを機に試してみてください。

脚注
  1. 単にAPIというともっと広い意味になりますが今回は単純化のためにWebシステムにおけるネットワーク通信(Web API)の意味で使います ↩︎

  2. 設定で無効にすることはできます ↩︎

Ubie テックブログ

Discussion