本記事はこのリポジトリでやったことのまとめです。
GraphQL + TypeScript への課題感
TypeScript(に限らず他の静的型付の言語) と GraphQL を使うと、型の二重定義が発生がちです。折角 GraphQL に通信規約としての型を書いているのに、それを多重定義することで、運用の面倒臭さやバグの温床になりかねない、という懸念がありました。
今回は、graphql のスキーマとクエリを書くと、サーバー向けに resolver の型定義、クライアント向けにクエリの型定義を生成し、それによってできるかぎり型安全なコードを扱うのをゴールとします。
やり方
graphql-code-generator を使います。(というか今回は主にこのライブラリの紹介です)
期待してるディレクトリ構造はこんな感じ
├── 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 の練習だったんですが…)
生成された型定義によって MutationResolvers
と QueryResolvers
が手に入るので、これによってリゾルバの実装に型をつけることができます。
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 を使うのが楽です。