Edited at

graphql-codegen で型定義を生成する (React, Apollo, TypeScript)

本記事はこのリポジトリでやったことのまとめです。

https://github.com/mizchi-sandbox/graphql-react-apollo-playground


GraphQL + TypeScript への課題感

TypeScript(に限らず他の静的型付の言語) と GraphQL を使うと、型の二重定義が発生がちです。折角 GraphQL に通信規約としての型を書いているのに、それを多重定義することで、運用の面倒臭さやバグの温床になりかねない、という懸念がありました。

今回は、graphql のスキーマとクエリを書くと、サーバー向けに resolver の型定義、クライアント向けにクエリの型定義を生成し、それによってできるかぎり型安全なコードを扱うのをゴールとします。


やり方

graphql-code-generator を使います。(というか今回は主にこのライブラリの紹介です)

https://graphql-code-generator.com

期待してるディレクトリ構造はこんな感じ


├── graphql
│   ├── mutations
│   │   ├── addUser.graphql
│   │   └── deleteUser.graphql
│   ├── queries
│   │   ├── user.graphql
│   │   └── users.graphql
│   └── schema.graphql
├── client
│   ├── index.html
│   └── main.tsx
├── server
│   ├── index.ts
│   ├── package.json
│   └── resolvers.ts
└── codegen.yml

ごちゃごちゃしてますが、

graphql にスキーマ定義を

client が クライアント実装を

server が GraphQL サーバー実装

です。


型を生成する

server/gen に graphql resolver の型定義を、 client/gen に graphql の query と mutation の hooks API を生成するとします。

パッケージが細かく別れていて、依存がやたら多いですが、この辺をインストールします。

yarn add -D @graphql-codegen/cli @graphql-codegen/introspection @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-resolvers @graphql-codegen/typescript-react-apollo

yarn graphql-codegen init で生成してもいいですが、今回は次のような codegen.ymlを書きました。

overwrite: true

schema:
- ./graphql/schema.graphql
documents:
- ./graphql/queries/*.graphql
- ./graphql/mutations/*.graphql
generates:
./server/gen/graphql-resolver-types.ts:
plugins:
- typescript
- typescript-resolvers
./client/gen/graphql-client-api.tsx:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
config:
withComponent: false
withHooks: true
withHOC: false
./graphql/schema.json:
plugins:
- introspection


  • typescript-resolver を使ったリゾルバ用型定義

  • typescript-react-apollo を使った React 向け型定義

  • schema.json

の吐き出すことを意図してます。

yarn 経由でコマンドを叩いてコード生成

$ yarn graphql-codegen --config codegen.yml

✔ Parse configuration
✔ Generate outputs

どういうコードが生成されたかは、使いながら確認していきます。


サーバーサイドのリゾルバーの型定義

typeorm を使った簡単な CRUD のサンプルです。(というか正直言うと今回作ったリポジトリは typeorm の練習だったんですが…)

生成された型定義によって MutationResolversQueryResolvers が手に入るので、これによってリゾルバの実装に型をつけることができます。

import { ulid } from 'ulid';

import {
MutationResolvers,
QueryResolvers,
Resolvers,
} from './gen/graphql-resolver-types';
import { User } from './entity/User';

const Query: QueryResolvers = {
async user(_parent, args, _context, _info) {
const user = await User.findOne({ id: args.id });
return user || null;
},
async users() {
const users = await User.find();
return users;
},
};

const Mutation: MutationResolvers = {
async addUser(_parent, args, _context, _info) {
const newUser = new User();
newUser.id = ulid();
newUser.name = args.name;
await User.save(newUser);
return newUser;
},
async deleteUser(_parent, args, _context, _info) {
const user = await User.findOne(args.id);
await User.delete(args.id);
return user;
},
};

export const resolvers: Resolvers = {
Query,
Mutation,
};

VSCode などで触ればわかるのですが、第二引数の args に型がつき、返り値にも型を要求されます

生成コードを見ると、次のようなコードが生成されているのがわかります。

//...

export type MutationResolvers<Context = any, ParentType = Mutation> = {
addUser?: Resolver<User, ParentType, Context, MutationAddUserArgs>,
deleteUser?: Resolver<User, ParentType, Context, MutationDeleteUserArgs>,
};

export type QueryResolvers<Context = any, ParentType = Query> = {
user?: Resolver<Maybe<User>, ParentType, Context, QueryUserArgs>,
users?: Resolver<Maybe<Array<User>>, ParentType, Context>,
};

これで resolver を安心して書くことが出来るようになりました。


クライアントで生成されたコードを使う

↑ で作成された graphql server を http://localhost:3333 で動かすのを前提としています。

各種セットアップは略。リポジトリを見てください。手を抜くのに parcel を使っています。

生成されたコードをどう使っているか、次のコードの import ... from './gen/graphql-client-api' と UserList の hooks に注目してください

import React from 'react';

import ReactDOM from 'react-dom';
import { ApolloClient } from 'apollo-client';
import { createHttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { ApolloProvider } from 'react-apollo';
import { ApolloProvider as ApolloHooksProvider } from 'react-apollo-hooks';
import {
useUsersQuery,
useAddUserMutation,
useDeleteUserMutation,
} from './gen/graphql-client-api';

const client = new ApolloClient({
link: createHttpLink({
uri: 'http://localhost:3333/graphql',
}),
cache: new InMemoryCache(),
});

function UserList() {
const usersQuery = useUsersQuery();
const addUserMutation = useAddUserMutation();
const deleteUserMutation = useDeleteUserMutation();

return (
<>
<h1>Users</h1>
<ul>
{!usersQuery.loading &&
usersQuery.data.users.map(user => {
return (
<li key={user.id}>
{user.id}:{user.name}
<button
onClick={async () => {
await deleteUserMutation({ variables: { id: user.id } });
usersQuery.refetch();
}}
>
delete
</button>
</li>
);
})}
</ul>
<button
onClick={async () => {
await addUserMutation({
variables: { name: Math.random().toString() },
});
usersQuery.refetch();
}}
>
addUser
</button>
</>
);
}

function App() {
return (
<ApolloProvider client={client}>
<ApolloHooksProvider client={client}>
<UserList />
</ApolloHooksProvider>
</ApolloProvider>
);
}

ReactDOM.render(<App />, document.querySelector('.root'));

副作用を起こしたら usersQuery.refetch() を発行しています。


感想

スキーマからコードを生成したことで、自分で定義した部分は最小限で、かつ TypeScript の型チェックを効かせることができました。

GraphQLと型、なんか全体的にもったいない感覚がしてたのが、これによってだいぶ楽になった気がしていて、やっとプロダクションに突っ込むやる気が出てきた気がします。

厳密には、typeorm と GraphQL 間で依然として二重定義のような部分は残ってるのですが、ここは GraphQL は ORM ではないという点を鑑みて、分離したままにしておいたほうがいいと思っています。

GraphQL を ORM として使いたいなら prisma を使うのが楽です。

prisma - 最速 GraphQL Server 実装 - Qiita