NestJSのGraphQL Resolver関数を型安全にしたい
ユビーではNestJSでGraphQLのサーバー実装をおこなっています。今回は実践で得られた知見を元にNestJSでGraphQLのResolverに対してGraphQLのスキーマから生成したTypeScriptの型を適用する方法について解説します。
前提としてNestJSにはスキーマファーストとコードファーストがありますが、今回はスキーマファーストで書いたうえで、スキーマから型を生成するアプローチを紹介します。
NestJS組み込みの型生成を使う
NestJSのスキーマファーストのアプローチではNestJSの組み込みの機能でスキーマからTypeScriptの型を生成することができます。
以下のように書くことで、 graphql.ts
に型が生成されます。
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
typePaths: ['./**/*.graphql'],
definitions: {
path: join(process.cwd(), 'src/graphql.ts'),
},
}),
今回は以下のようなスキーマを例にします。
type Query {
post(id: Int!): Post!
}
type Post {
id: Int!
title: String!
author: User!
}
type User {
id: Int!
name: String!
}
このスキーマに対して以下の型が生成されます。
export interface IQuery {
post(id: number): Post | Promise<Post>;
}
export interface Post {
id: number;
title: string;
author: User;
}
export interface User {
id: number;
name: string;
}
この型を使って以下のようにResolverに型をつけることができます。
@Resolver()
class QueryResolver implements IQuery {
@Query()
async post(@Args("id") id: number): Promise<Post> {
// ...
}
}
一見よさそうですが、この型情報ではこのくらいのシンプルなユースケースしか満たせません。例えば、以下のような @Parent
や @Context
を引数にとるResolver、ネストしたフィールドのResolverなどではこの生成された型を利用できません。
@Resolver("Post")
class PostResolver {
@ResolveField()
async author(@Parent() post: Post, @Context() ctx: MyContext): Promise<User> {
// Use ctx here ...
return this.userService.findUser(post.authorId);
}
}
GraphQL Codegenで型生成する
そこでGraphQL/TypeScript界ではおなじみのGraphQL Codegenを使って型を生成します。
typescript-resolver
を使ってこんな感じでセットアップします。
$ npm i -D @graphql-codegen/cli @graphql-codegen/typescript-resolvers @graphql-codegen/typescript
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'schema.graphql',
generates: {
'./resolvers-types.ts': {
plugins: ['typescript', 'typescript-resolvers'],
},
},
};
export default config;
しかし、これで出力されるResolver関数の型は以下のようになります。
export type ResolverFn<TResult, TParent, TContext, TArgs> = (
parent: TParent,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo
) => Promise<TResult> | TResult;
つまり以下のような関数に適用するケースを想定しています。
function post(
parent: Parent,
args: { id: number },
context: Context,
info: GraphQLResolveInfo
): Promise<Post> {
// ...
}
これはNode.jsでGraphQLのResolverを実装するときの一般的なシグネチャなのでほとんどのケースではこの型で要件を満たすのですが、でNestJSは @Args
や @Context
などのアノテーションで自由に引数の位置を指定することができます。GraphQL Codegenの設定で ResolverFn
をカスタムすることは可能ですが、アノテーションの位置で変わる引数を考慮した型にするのは難しそうです。
そこでまずは生成された型の一部だけを利用する方法を考えます。
import { QueryPostArgs, Post } from "./resolvers-types";
@Resolver()
class QueryResolver {
@Query()
async post(@Args() args: QueryPostArgs): Promise<Post> {
// args.id: number
}
}
これで入力値や返り値についてはスキーマから生成した型がつきます。NestJSのアノテーションの機能を使いつつ、生成された型をlightweightに使う場合はこれくらいがバランスがよいと思います。
もっと厳密な型検査をしたい
前述の方法では、入直値や返り値に指定した型がそもそも間違っている場合の型検査ができません。
async post(@Args() args: QueryUserArgs): Promise<User> {
// ...
}
例えばこのように post
Resolver関数に対して間違えて QueryUserArgs
や User
を指定しても型検査は通ってしまいます。せっかくもっと厳密に検査できる型が生成されているのにもったないですよね。しかし、NestJSのアノテーションによる引数の指定と型生成はどうやったって相性が悪いので、アノテーションを使わないという方法を採用することにしました。
NestJSのResolver関数は引数にアノテーションを使わないと、以下のようなシグネチャで引数を受け取ることができます。
@Resolver()
class QueryResolver {
@Query()
async post(
parent: Parent,
args: { id: number },
context: Context,
info: GraphQLResolveInfo
): Promise<Post> {
// ...
}
}
つまりGraphQL Codegenで指定した型をそのまま適用できるということです。以下のように、Resolver関数が定義されている型を implements
するだけです。
import { QueryPostArgs, QueryResolvers, Post } from "./resolvers-types";
@Resolver()
class QueryResolver implements QueryResolvers {
@Query()
async post(_: unknown, args: QueryPostArgs): Promise<Post> {
// ...
}
}
context
や info
は使わないので省略、parent
は使わないけど引数の順番は固定なので _
で捨てています。これでpost関数の入直値や返り値がスキーマと不一致だった場合に型検査でエラーになります。
また、以下のようにネストしたオブジェクト型やContextを使う場合は以下のように書くことができます。
import { PostResolvers, Post, User } from "./resolvers-types";
@Resolver("Post")
class PostResolver implements PostResolvers {
@ResolveField()
async author(parent: Post, _: unknown, ctx: MyContext): Promise<User> {
// ...
}
}
アノテーションを使った引数などのNestJSらしさは若干失われますが、それと引き換えに得られる型安全性のほうが大きいと個人的には思います。
implementsする型を厳密にする
ここまでで欲しかったものはだいたい得られたのですが、もう一歩踏み込んでみましょう。以下のコードは型チェックは通りますが、意図した挙動にはなりません。
@Resolver("Comment")
class PostResolver implements PostResolvers {
@ResilveField()
async author(parent: Post): Promise<User> {
// ...
}
}
@Resolver
の引数が Comment
になっているので、これは Comment
というオブジェクト型のauthor
フィールドとして解決されますが、parentがPost
型になっており実際に渡ってくる引数と型が食い違います。NestJSはアノテーションによって解決するオブジェクトの型を決めており、アノテーションに対して型の制約をかけられないのでしょうがないと思っていたのですが、実際にこの問題によってバグが入り込んだケースがあったので、ESLintを使って解決することにしました。
以下のように@Resolver
に指定した引数の名前とimplements
で指定した型名を調べてマッチするかどうかを検査するだけです。こういうのは生成AIがほぼ正確に書いてくれるので便利ですね。
module.exports = {
create(context) {
return {
Decorator(node) {
if (node.expression.callee.name === "Resolver") {
const arg = node.expression.arguments[0];
if (arg === undefined) {
context.report({
node,
message: "Resolver decorator must have an argument",
});
return;
}
const className = node.parent.id.name;
const classImplements = node.parent.implements;
const impl = classImplements?.[0];
const expectedInterface = `${arg.value}Resolvers`;
if (impl === undefined) {
context.report({
node,
message: `Class must implement ${expectedInterface}`,
});
return;
}
const implementedInterface = impl.expression.name;
if (expectedInterface !== implementedInterface) {
context.report({
node,
message: `Class ${className} implements ${implementedInterface} but expected ${expectedInterface}`,
});
}
}
},
};
},
};
これでより安心安全なResolver関数を得ることができました。満足です。
終わりに
NestJSのResolverを厳密に型検査する方法について紹介しました。5/10に行われるTSKaigiでは、GraphQLとTypeScriptの型付けについてもう少し網羅的にクライアント、サーバー両方の視点でのプラクティスについて話す予定です。もし興味があるかたは聞きにきてください!
Discussion