📊

aileadにおけるGraphQLの使い方の反省と最強に型安全なGraphQLスキーマへの道

2023/01/04に公開4

皆さんこんにちは。株式会社バベルでエンジニアをしている uhyo です。バベルが提供しているaileadというプロダクトではフロントエンドとバックエンドの通信にGraphQLが使用されています。

実は、当初のaileadにおけるGraphQLの使い方は望ましいものではありませんでした。そこで、筆者はGraphQLの使い方を改善し、最終的に他では類を見ないくらい型安全にGraphQLを利用する仕組みを構築しました。この記事では、従来のGraphQLの使い方がどのように良くなかったのかを紹介し、それを克服するために行ったことを解説します。

GraphQLの良くない使い方

まず、従来の良くないGraphQLの使い方を例を挙げて紹介します。ただし、以降に出てくるコードは例であって実際のサービスで使われているものではありません。その点はご了承ください。

例えば、自分の組織の中のユーザーを全部取得できるクエリがあるとします。おおよそこんな感じです。

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

extend type Query {
  # 自分の組織の全てのユーザーを取得
  allUsersInOrganization: [User!]!
}

実際のスキーマではフィールドに!をつける習慣がなく全てがnullableでそれもまた阿鼻叫喚だったのですが、それは一旦置いておきます。

ここに、ホーム画面で使うために自分のユーザー情報を追加するクエリを追加してみます。

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

extend type Query {
  # 自分の組織の全てのユーザーを取得
  allUsersInOrganization: [User!]!

  # 自身の情報を取得
  user: User!
}

ここまでは良さそうですね。では、実はユーザーは投稿 (Post) をすることができ、ホーム画面では自身の情報に加えて自身の投稿一覧も表示したいとします。このときどのような設計にするのが良いか、みなさんはお分かりでしょうか。

おそらく、適切な設計な次のように、UserのフィールドとしてPostの一覧を取得できるようにする方法でしょう。

type User {
  id: String!
  name: String!
  posts: [Post!]!
}

extend type Query {
  # 自分の組織の全てのユーザーを取得
  allUsersInOrganization: [User!]!

  # 自身の情報を取得
  user: User!
}

# 取得するときはこのようにする
query {
  user {
    id
    name
    posts {
      ...
    }
  }
}

しかし、ここで一つ問題がありました。

aileadではネストしたリゾルバーを使っていなかった

設計としては上記のようにすれば問題ないのですが、ここでひとつ問題がありました。それは、aileadではネストしたリゾルバーを使っていなかったということです。

リゾルバーはGraphQLのサーバーサイドで用いられる概念であり、型のフィールドの取得を担当する関数です。ちなみに、aileadではサーバーサイドにApollo Serverを使用しています。

実は、aileadではトップレベルのリゾルバーのみで全てを済ませていました。上の例で言うと、allUsersInOrganizationuserQueryのフィールドであるためこれらをトップレベルのリゾルバーと考えます。これらに対してはリゾルバーが定義されており、DBからデータを取ってきて[User!]とかUser!に相当するオブジェクトを返すようになっています。

本来は、トップレベル以外のフィールドにもリゾルバーを定義することができます。上の例では、User型のidname、そしてpostsに対してもリゾルバーを定義できます。

しかし、idとかnameといった細々としたフィールドに対していちいちリゾルバーを定義するのは面倒です。その時に活躍するのがDefault Resolverです。これにより、Userを返すトップレベルのリゾルバーがUser全体を表すオブジェクトを返すようにしていれば、Userの各フィールドを取得する際はそれが自動的に返されるようになり、個々のフィールドに対してリゾルバーを定義する必要がなくなります。

  // user に対するトップレベルのリゾルバー
  user: async () => {
    // ...
    return {
      id: ...,
      title: ...,
      posts: ...,
    }
  },

さて、aileadの場合、ネストしたリゾルバーを使っていなかったと述べました。ということは、ちょうど上のコードのように、トップレベルのuserリゾルバーが呼び出された場合は、返されるUserpostsの中身まで埋めて返していたということです。このリゾルバーが呼び出された場合は、自身のユーザー情報をデータベースから取得するだけでなく、ユーザーに紐づいたPostの情報まで取得していることになります。

一方で、ユースケース的に、allUsersInOrganizationから返されるUserの配列についてはPostの情報が必要ないとしましょう。この場合はわざわざ不要な情報をデータベースに問い合わせたくありませんから、UserにPostの情報を含めたくありません。

GraphQLのやり方では、これらのクエリを使用する側でUserpostsを結果に含めるかどうか制御するべきです。

query {
  allUsersInOrganization {
    id
    name
    # ここに posts を含めなければpostsが取得されない
  }
}

しかし、ネストするリゾルバーを使っていなかったため、このようなやり方を採ることができていませんでした。postsもDefault Resolverで返されるため、User型のデータにはあらかじめpostsを用意しておかないとクライアントで取得することができません。

ネストしたリゾルバーを使わないという制約(事情があったというよりは、単にGraphQL的な設計方法を知らないまま実装してしまっただけだと思います)の中で余計なデータフェッチングを避けようとすると、何が起こるでしょうか。それは、「どのリゾルバーから取得したUserかによって、postsが入っているかどうかが異なる」という現象が発生します。クライアント側でもそれを織り込み済みで実装します。幸か不幸か、サーバーもクライアントも1人の担当者が実装する仕組みなので実装時には大した問題は起こりません。このようなやり方だと、必然的に1ユースケースに対して1つQuery直下のリゾルバーが生えることになり、GraphQLの利点は活かされません。

null安全性にも問題が!

ところで、GraphQLをTypeScriptから使う際の頼れる味方、GraphQL Code Generatortypescriptプラグインを使用する場合には、必須(posts: [Post!]!)となっているpostsフィールドを省略することができません。そのため、場合によってpostsが返ってきたりこなかったりする場合は、Userpostsフィールドは省略可能という定義にせざるを得なくなります。

type User {
  id: String!
  name: String!
  posts: [Post!] # ← 省略可能になった
}

extend type Query {
  # 自分の組織の全てのユーザーを取得
  allUsersInOrganization: [User!]!

  # 自身の情報を取得
  user: User!
}

こうすると、今度はpostsが返されるはずのuser側でもpostsはnullableになってしまいます。これが意味することは、userリゾルバーの結果を使う側にはpostsがnullableであるように見えてしまい、本来は必要ないエラー処理とか、あるいは?? []のようなワークアラウンドが必要になってしまうということです。

サーバーサイドから本来返されるものがスキーマ上nullableになっているのは、コードの品質担保の面でよくありません。実際に実行されることがない適当なnull処理を実装して型を誤魔化してしまいがちですが、のちのちサーバーサイドの変更で実際にnullが返ってくるようになり、それに起因してバグが発生したことが実際にありました。

そのため、nullが返ってくる可能性があるかどうかというのは、スキーマ上で厳密に管理したいものです。これがこの記事の目標です。

採らなかった解決策

上のスキーマのQueryのフィールドを見ると、allUsersInOrganizationが返すUserと、userが返すUserは、Postの情報を含むかどうかと言う点で性質が異なります。典型的なクラスベースオブジェクト指向の考え方では、このようなものは同じ型で表現するべきではありません。(cf. 関心の分離を意識した名前設計で巨大クラスを爆殺する

この観点では、2つのUserをユースケースに合わせて別々の型にすることになります。

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

type UserWithPosts {
  id: String!
  name: String!
  posts: [Post!]!
}

extend type Query {
  # 自分の組織の全てのユーザーを取得
  allUsersInOrganization: [User!]!

  # 自身の情報を取得
  user: UserWithPosts!
}

こうするとnullableが消えたため一見良さそうですが、これはGraphQL的に望ましい解決策ではありません。そもそも、こうするとユースケースごとに異なる型、そしてそれを返す異なるトップレベルの(Queryのフィールドの)リゾルバーが必要になります。このような設計だと、GraphQLの利点を活かせていません。ユースケースの数だけ異なるリゾルバーを用意するのは画面ごとにHTTP APIを用意しているのととくに変わらず、REST API未満のものをスキーマだけGraphQLで書いたような状態になってしまっています。GraphQLのスキーマ記述言語は強力なのでそれはそれでよいのかも知れませんが、リゾルバー実装側でも同じようなコードを何度も書かなければいけなくなるなどの都合から、これは望ましい解決策ではないと思われました。

GraphQL的なやり方では、ユースケースはAPIで表現するのではなくクエリの形で表現すべきです。そのため、どのリゾルバー由来であっても、Userオブジェクトが取得できるならばそれに付随したPostオブジェクトを取得できるべきです。そのためにはUser型にpostsを持たせておき、ユーザー情報を返す全てのリゾルバーがUser型を返すように統一されているべきです。

上で説明した「postsが必要ない場合でもDBから取得されてしまう問題」に対しては、postsをネストしたリゾルバー(Userpostsフィールドに対するリゾルバー)として定義すべきです。ネストしたリゾルバーであれば、Userを取得するトップレベルのリゾルバーはまだpostsを含まないオブジェクトを返し、クライアントがpostsを要求したときはネストしたリゾルバーが実行されてDBからの取得が走るようにできます。

ということで、ここからは、このゴールを目指してどのような施策を行ったのかを紹介します。

理想的な方法と型安全性の問題

これまでの説明から考えて、理想的なスキーマはやはりこうです。

type User {
  id: String!
  name: String!
  posts: [Post!]!
}

extend type Query {
  # 自分の組織の全てのユーザーを取得
  allUsersInOrganization: [User!]!

  # 自身の情報を取得
  user: User!
}

特に、Userpostsがnullableになっていない点に注目してください。これは、「取得しようとすれば必ず取得できる」と言う意味になります。このスキーマでは、取得しようとしたけどnullだったということは起こりません。

また、前述のようにpostsUserのフィールドに対するリゾルバーとすべきです。このことから、リゾルバーの定義は次のような形になります。

const resolvers = {
  Query: {
    // トップレベルのリゾルバーたち
  },
  User: {
    posts: async (user) => {
      // Userのpostsフィールドが取得されたときの処理を書くリゾルバー
    },
  }
};

ここで問題になるのが型安全性です。GraphQL Code Generatorを使っている場合、よくあるセットアップではnon-nullableなフィールドとネストしたリゾルバーの噛み合わせが悪いのです。

Graphql Code Generatorセットアップの例

典型的な設定例として、次のようなセットアップを考えてみます。

codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'
 
const config: CodegenConfig = {
  schema: './schema.graphql',
  generates: {
    './src/resolvers.d.ts': {
      plugins: ['typescript', 'typescript-resolvers'],
    }
  }
}
export default config

普通にtypescriptとtypescript-resolversを使うセットアップです。そして、次のGraphQLスキーマとresolverの定義を考えます。

type User {
  id: String!
  name: String!
  posts: [Post!]!
}

type Post {
  id: String!
  title: String!
}

extend type Query {
  # 自分の組織の全てのユーザーを取得
  allUsersInOrganization: [User!]!

  # 自身の情報を取得
  user: User!
}
import { Resolvers } from "./resolvers";

export const resolvers: Resolvers = {
  Query: {
    user: async ()=>{
      return {
        id: "uhyo",
        name: "uhyo"
      }
    },
    allUsersInOrganization: async () => {
      return [{
        id: "uhyo",
        name: "uhyo",
      }, {
        id: "pikachu",
        name: "pikachu"
      }]
    }
  },
  User: {
    posts: async (user) => {
      // 本当はuserに紐づいたPostsを取得する
      return [
        {
          id: "pika",
          title: "pika!"
        },
        {
          id: "chu",
          title: "chu!"
        }
      ]
    }
  }
};

上のリゾルバー定義では、Userを返すトップレベルのresolverはpostsを含んでいません。なぜなら、postsのデータはUserに対して定義されたリゾルバーで取得されるはずだからです。しかし、実はこれだと型エラーが出てしまいます。型エラーの内容は、userallUsersInOrganizationが返すUserpostsが含まれていないというものです。

上のコードに対して型エラーが発生している様子

このコードは実際に以下のリポジトリのmainブランチで確かめることが可能です。

https://github.com/uhyo/graphql-resolvers-test/tree/main

残念ながら、既存のオプション等を組み合わせても、型安全性を保ちつつ型エラーを解消することはできませんでした。ということで、これに対する解決策を実装した、というのがこの記事の本題です。

妥協した解決策の例

このような問題はGraphQLを使っていると普通に発生すると思うのですが、世の中のユーザーがどのように解決しているのか調べてもよく分かりませんでした。考えられる(しかし完璧ではない)解決策を挙げておきます。これらのやり方に心当たりがある方は、この記事の内容が参考になるでしょう。

ダミーの値を返す

型エラーが発生しないように、ダミーの値を返す方法です。言うまでもなく、ダミーの値がロジックに含まれるのはコードの読解を阻害するのでよくありません。

export const resolvers: Resolvers = {
  Query: {
    user: async ()=>{
      return {
        id: "uhyo",
        name: "uhyo",
        posts: [] // 実際には使われない
      }
    },
    allUsersInOrganization: async () => {
      return [{
        id: "uhyo",
        name: "uhyo",
        posts: [] // 実際には使われない
      }, {
        id: "pikachu",
        name: "pikachu",
        posts: [] // 実際には使われない
      }]
    }
  },
  User: {
    posts: async (user) => {
      // 本当はuserに紐づいたPostsを取得する
      return [
        {
          id: "pika",
          title: "pika!"
        },
        {
          id: "chu",
          title: "chu!"
        }
      ]
    }
  }
};

フィールドをオプショナルにする

次のように、後からリゾルバーによって埋められるところはオプショナルにするという方法も考えられます。

type User {
  id: String!
  name: String!
  posts: [Post!] # オプショナルになった
}

type Post {
  id: String!
  title: String!
}

extend type Query {
  allUsersInOrganization: [User!]!

  user: User!
}

こちらの方法も完璧ではありません。上で説明したように、実装上必ず存在する値に対してnullの対応を書かないといけないからです。

mappersオプションの使用

詳細は省略しますが、typescript-resolversにはmappersというオプションがあり、これを使うことで型の内部表現をスキーマ上の型とは別にすることができます。例えば、この記事の目的を達成するためには「リゾルバーがUserオブジェクトを返す際はpostsを含まないオブジェクトを返してよい」というような設定ができます。

これを使えば型安全性なResolvers型を作ることができそうですが、問題が2つあるため今回採用しませんでした。

  1. 全ての型に対して手動で設定を書くのがとても面倒。
  2. Default Resolverが使えるところと使えないところを型上で区別できないため、カスタムリゾルバーが必要なのに実装し忘れるといったミスを防ぐことができない。(もしくは、Default Resolverを活用することを全く諦めてあらゆるリゾルバーを明記する必要が出てくる。)

以下ではいよいよ、面倒くさくなくてかつ完璧な解決策を紹介します。

カスタムディレクティブによる解決

GraphQLでは、ディレクティブという仕組みにより、スキーマ上の色々な場所にアノテーションを付与することができます。

今回のアイデアは、リゾルバーが実装されているべきフィールドに@customResolverというディレクティブを付与するというものです(Query直下は元々必要なので省略)。つまり、スキーマは次のようになります。

directive @customResolver on FIELD_DEFINITION

type User {
  id: String!
  name: String!
  posts: [Post!]! @customResolver
}

type Post {
  id: String!
  title: String!
}

extend type Query {
  allUsersInOrganization: [User!]!

  user: User!
}

そして、これに合わせてGraphQL Code Generatorの挙動をカスタマイズします。

  • typescriptプラグイン: @customResolverが付いたフィールドは生成される型に含めないようにする。これにより、リゾルバーが返すUser型はpostsが不要になる。
  • typescript-resolversプラグイン: @customResolverが付いたフィールドに対するリゾルバーは省略できないようにする。これにより、全てのフィールドに対して「デフォルトリゾルバーから値を取得できる」か「カスタムリゾルバーが存在する」のどちらかが必ず満たされるようになる。

我々は今のところ、既存のプラグインにモンキーパッチを当てるような形で上記のカスタマイズを運用しています。

typescriptのカスタマイズ
// Copyright 2023 Babel, Inc.
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

const {
  TsVisitor,
  plugin,
} = require('@graphql-codegen/typescript');

const FieldDefinition = TsVisitor.prototype.FieldDefinition;

Object.assign(exports, {
  TsVisitor,
  plugin: (...args) => {
    TsVisitor.prototype.FieldDefinition = function (...args) {
      /** @type {import('graphql').FieldDefinitionNode} */
      const node = args[0];
      if (node.directives?.some(d => d.name === "customResolver")) {
        return "";
      }
      return FieldDefinition.apply(this, args);
    };
    const ret = plugin(...args);
    TsVisitor.prototype.FieldDefinition = FieldDefinition;
    return ret;
  },
});
typescript-resolversのカスタマイズ
// Copyright 2023 Babel, Inc.
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

const utils = require('@graphql-tools/utils')
const {
  plugin,
} = require('@graphql-codegen/typescript-resolvers');

Object.assign(exports, {
  plugin: (...args) => {
    const newSchema = utils.filterSchema({
      schema: args[0],
      fieldFilter: (typeName, fieldName, config) => {
        if (typeName === "Query" || typeName === "Mutation") {
          return true;
        }
        return config.astNode.directives.some(d => d.name.value === "customResolver");
      }
    })
    args[0] = newSchema;
    const ret = plugin(...args);
    return ret;
  },
});

GraphQL Code Generatorの設定を、これらのプラグインを使用するように書き換えましょう。さらに、そもそもresolverの書き忘れを防ぐようにavoidOptionals: trueオプションも設定します。これが無いと、リゾルバーが全てオプショナルプロパティになってしまいます。

codegen.ts(カスタムプラグイン使用)
import type { CodegenConfig } from '@graphql-codegen/cli'
 
const config: CodegenConfig = {
  schema: './schema.graphql',
  generates: {
    './src/resolvers.d.ts': {
      plugins: ['./plugins/typescript.js', './plugins/typescript-resolvers.js'],
      config: {
        avoidOptionals: true
      }
    }
  }
}
export default config

以上の設定により、リゾルバーを過不足なく定義しないと型エラーが出るような環境を整えることができました。実際に、下のコードで型エラーが出なくなります。もちろん、Userpostsフィールドのリゾルバーを書き忘れてしまうと型エラーになります。

import { Resolvers } from "./resolvers";

export const resolvers: Resolvers = {
  Query: {
    user: async ()=>{
      return {
        id: "uhyo",
        name: "uhyo"
      }
    },
    allUsersInOrganization: async () => {
      return [{
        id: "uhyo",
        name: "uhyo",
      }, {
        id: "pikachu",
        name: "pikachu"
      }]
    }
  },
  User: {
    posts: async (user) => {
      // 本当はuserに紐づいたPostsを取得する
      return [
        {
          id: "pika",
          title: "pika!"
        },
        {
          id: "chu",
          title: "chu!"
        }
      ]
    }
  },
  Post: {}
}

以上の施策により、ネストしたリゾルバーを活用しつつ、リゾルバーの過不足を型で防ぐ環境が整いました。

ただ、最後にひとつ隠し味を加えましょう。上のコードをよく見ると、最後にPost: {}とあります。avoidOptional: trueを付けたことにより、中身を書く必要がないオブジェクトでも省略が許されなくなりました。このままでも悪くはありませんが、せっかくなので修正したいですね。

これをプラグイン側で対処するのは少し大変なので、利用側でresolvers型を加工します。TypeScriptならばこういった型の加工は朝飯前です。

加工用のコード
// Copyright 2023 Babel, Inc.
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
type ResolverKeys = {
  [K in keyof Resolvers as {} extends Resolvers[K] ? 'empty' : 'nonempty']: K;
};
// Resolversから {} をoptionalにしたもの
type ResolversWithoutEmptyKeys = Pick<Resolvers<Context>, ResolverKeys['nonempty']>;

export type { ResolversWithoutEmptyKeys as Resolvers };

このコードで得られるResolvers型を使うようにすれば、Post: {}は省略できます。嬉しいですね。

以上の内容は実際に以下のリポジトリのcustom-pluginsブランチで確かめることが可能です。

https://github.com/uhyo/graphql-resolvers-test/tree/main

まとめ

この記事では、GraphQLのリゾルバーをTypeScriptで定義するにあたって、最大限の型安全性を得る方法を紹介しました。特に、Graphql Code Generatorのプラグインをそのまま使うだけでは型安全性が十分ではないことを指摘し、その対応策を説明しました。具体的には、Apollo Serverのデフォルトリゾルバーとカスタムリゾルバーを排他的に利用するために、カスタムリゾルバーが必要なところはスキーマ上で@customResolverディレクティブを明記するという方法を紹介しました。

この方法は比較的お手軽に実装可能です。必要なコードはこの記事に全てMITライセンスで掲載していますので、皆さんもすぐに利用開始することができます。

2023年はより型安全にGraphQLを利用していきましょう。

Babel, Inc. Tech Blog

Discussion

takezoux2takezoux2

指定されていないResolverのフィールドはデフォルト値で埋めるということをやれば良いので下のようなコードなどいかがでしょう


const DefaultValues = {
  posts: []
}
type KeysWithDefault = keyof typeof DefaultValues;
const withDefaults = ( user: Omit<User, KeysWithDefault>): User => {
  return Object.assign({}, DefaultValues, user)
}

export const resolvers: Resolvers = {
  Query: {
    user: async ()=>{
      return withDefaults({
        id: "uhyo",
        name: "uhyo"
      })
    },
   ...
  },
  User: {
    posts: async (user) => {
      // 本当はuserに紐づいたPostsを取得する
      return [
        {
          id: "pika",
          title: "pika!"
        },
        {
          id: "chu",
          title: "chu!"
        }
      ]
    }
  }
};
uhyouhyo

ありがとうございます。そのやり方は、型に合わせてダミーの値を用意したり、値を返すときに毎回withDefaultsで囲ったりといったコードがドメインロジックに混ざってしまうとコード読解のノイズになるので、それを嫌って採用しませんでした。🥲

kk

何かしら対策ありそうなのにと思ってドキュメントを読んでみたらブログでmappersを使ってねって書いてありますね。
https://the-guild.dev/blog/better-type-safety-for-resolvers-with-graphql-codegen

読んでる感じでは考え方を少し変えるべきで

resolverがUserオブジェクトを返す際はpostsを含まないオブジェクトを返してよい

というのではなく内部的なUserモデルとGraphQLが扱うUserモデルは違うので分けて考えて、マッピングして紐づけてねという感じかなと思います。
たしかにマッピングは面倒臭いですが、内部とGraphQLのモデルが一致する場合は省略可能なので、記述する量としてはカスタムディレクティブと対して変わらないですし、公式が紹介しているやり方の方がわかりやすかったりはしないでしょうか。(他の人に説明するさいにブログのリンクを貼るだけでいいですし

uhyouhyo

ありがとうございます。そちらの記事は多分見ていなかったと思います。

確かにmappersはすでにあるオプションなので理解しやすさの面では筋がよいと思います。
ただ、その方法だと型安全性が心配で、必要なresolverが実装されていないといったミスを型で防げないのではないかと思います。(この点は本文に追記しました。)
この記事で紹介した方法はどんな入力が与えられても型安全性が守られるはずで、そこは気に入っています。

ただ、この記事の方針だと今後「内部的なモデル」を単に一部のプロパティが無いだけでなく内部用のデータを持ってるようなものにしたくなった場合に、その情報を全部スキーマに書いていく(@これは外に見せない みたいな)必要が出てきそうなのはちょっと考えものだと思っています。