TypeScriptとGraphQLで実現する型安全なAPI実装
この記事はTSKaigi2024での以下の私の発表内容を書き下ろしたものです。
なぜ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のレスポンスでname
がnull
だった場合、プログラムは実行時にエラーになる可能性があります。これを解決するために以下のいずれかが必要になります。
-
name
はNullableがAPIの仕様であればクライアント側の型が間違っているのでクライアント側を直す -
name
はNonNullがAPIの仕様であればサーバー側のバグなのでサーバー側を直す。
しかし、仕様の確認やそれに合わせた型定義を人の手で毎回やるのは面倒すぎますし、必ずミスが発生します。これを解決するアプローチとして、APIの仕様を型のあるスキーマとして定義し、それを元に型や実装を自動生成することでAPIの呼び出しやサーバーの実装をより堅牢にするというのが今回の趣旨です。
APIに型をつけるとは
今回、「APIに型をつける」とは以下のような内容として扱います。
- APIの仕様をスキーマで定義する
- スキーマから実装言語の型を生成する
- 生成された型を使って実装する
- クライアントの実装
- サーバーの実装
まずはAPIの仕様を型のあるスキーマで定義し、そのスキーマをつかってクライアント・サーバーの実装に対して型をつけます。このようなことを実現する技術としては色々ありますが、メジャーなところだと以下のようなものがあります。
- REST + OpenAPI
- gRPC
- GraphQL
今回はGraphQLについて紹介する前提なのでこれらの詳細な比較検討はしませんが、型安全性に着目するとgRPCのアプローチが筋がいいと個人的には思います。gRPCはProtocol Buffersから実装も含めて自動生成するので、デフォルトで型付きの実装が手に入ります。
それに比べるとGraphQLは今回紹介するような、オプトインで型をつける必要があるので型安全にするための強制力は弱くなりますが、クライアントから柔軟なクエリができるなどのメリットがあります。gRPCやGraphQL、RESTの比較は型安全性だけで語れるわけではありませんが、一つの比較要素にはなると思います。
例えばGraphQLでは以下のようなスキーマを定義します。
type Query {
user(id: Int!): User!
}
type User {
id: Int!
name: String!
}
これに対して以下のような型を生成します。
type Query = {
user: (id: number) => Promise<User>;
};
type User = {
id: string;
name: string;
};
これを次のように利用します。なお、これは話をわかりやすくするために極限までシンプルにした例なので実際は後述するようにもう少し複雑です。
const fetchUser: Query["user"] = async (id) => {
const { user } = await request(`
query getUser {
id
name
}
`);
return user;
};
const userResolver: Query["user"] = async (id) => {
const user = await db.users.find(id);
return user;
};
イメージとしてはこのような感じです。TypeScriptとGraphQLにおいてスキーマから型を生成するツールとしてデファクトになっているのがGraphQL Codegenというツールです。
GraphQL Codegenを使うと簡単に型の生成ができますが、クライアント、サーバーそれぞれで考えることやベストプラクティスは異なるので、それぞれ詳細に見ていきましょう。
クライアントサイドのプラクティス
クライアントサイドでは、公式が推奨しているClient Presetを使うのが現状ではベストプラクティスといえます。
これをインストールし、次のような設定を書いたら準備は完了です。
$ npm install @graphql-codegen/cli @graphql-codegen/client-preset
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のスキーマがあったとします。
type User {
id: ID!
name: String!
imageUrl: String!
}
ここから次のような型が生成されます。
type User = {
__typename?: 'User';
id: string;
name: string;
imageUrl: string;
};
これを使ってリクエストに型をつけてみます。
const user: User = await request(`
query getUser {
id
name
}
`);
この実装の問題点は、実際のリクエストに含まれていない imageUrl
も User
型に含まれることです。これでは型安全な実装とはいえません。そこで、スキーマから生成した型でなくクエリから生成した型を使います。次のようになります。
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の機能を使い、以下のようにクエリを分割します。
コンポーネントのコードは次のようになります。
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を利用したクエリの型を利用できなくなっています。型チェックを通すにはコンポーネント側を次のようにします。
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 Server や GraphQL Yoga などがあります。それらのパッケージの内部では公式のリファレンス実装である graphql-js が利用されていて、インターフェースが統一されているので GraphQL Codegen の Graphql Resolvers plugin を使うことでほとんどのフレームワークに対応することができます。
ただ、NestJSだけ少し特殊な事情があるので以下を参照してください。
Client Preset と同じように Server Preset というのもあるにはあるのですが、わりと癖が強めなので個人的には Resolvers plugin を使いつつ用途ごとに設定でカスタマイズするくらいがちょうどよいと思います。
ベースの設定はこのようになります。
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側のスキーマと実装はクライアント側とは少し変えて以下のようにします。
type Query {
user(id: ID!): User!
}
type User {
id: ID!
name: String!
company: Company!
}
type Company {
id: ID!
name: String!
}
// データベースのレコードに対する型
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);
}
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にあたります。これに対してスキーマから生成した型を付与すると以下のようになります。
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の実装を以下のようにします。
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
だからです。
type User = {
id: string;
name: string;
companyId: string;
};
type GqlUser = {
id: string;
name: string;
company: Company;
};
Query.user
では無駄な処理が実行されないようにcompany
を返さないように変更したのでcompany
がないのは当然ですね。一方でcompanyId
はGqlUser
にはないので以下も型エラーになります。
User: {
company: (user: GqlUser): Promise<GqlCompany> => {
// ここも型エラー
return findCompany(user.companyId);
},
},
これを解決するために、GraphQL Codegenのmappersという設定でResolverが扱う型を指定します。
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の実装は以下のようになります。
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を使った型生成を導入していなかったら、ぜひこれを機に試してみてください。
Discussion