ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

mongoose のpopulateの型を動的に生成する / Create a dynamic type for mongoose populate

こんにちは、株式会社ミツモアの坂本です

この記事はミツモアアドベントカレンダー2024 2日目の記事です。

振り返ると毎年TypeScriptの型に関する記事を書いていますね。

https://engineering.meetsmore.com/entry/2022/12/06/215335

https://engineering.meetsmore.com/entry/2023/12/13/173512

今までの記事は紹介系でしたが今年は実用系で行こうと思います。mongooseのpopulateやselectを適用した型を自動生成する記事になります。最後に型自体を公開しますので、よかったら使ってやってください。

ミツモアではmongodbとmongooseを組み合わせて利用しているのですが、mongooseの型はあまり強くありません。特にpopulateというRDBだとjoinに該当する機能を利用した際は全く型が補完されてくれません。populateはmongoose独自の機能で、mongodbそのものはリレーションの概念を持ちません。

populateの特徴として、同じフィールドを上書きする形でjoinしてしまうため、 user: ObjectId | User のような型にせざるを得ません。ここが型の解決をよりややこしくしています。

もし今0からmongodbでプロダクトを構築する場合はPrismaなどの強力な型の恩恵を受けたいですが、7年以上運用してきているプロダクトなためテーブルの数も多く、mongooseの機能に依存している部分もあるため簡単に移行できるような状態ではありません。

ただ、せっかくフルTypeScriptで開発しているのだから型の恩恵は存分に享受したい!ということで今回型パズルに挑むことにしました。

この型を説明するためにはいくつかのテーブルが必要になりますので、CMS的なものを題材にしましょう

ユーザーがいて、複数のポストがあり、各ポストにはコメントが複数投稿可能といった感じにしましょう。今回は型がテーマですので、このDBスキーマが適切かどうかは考えないものとします。

TypeScriptで型定義をすると以下のような構造となります。

type User = {
  _id: ObjectId
  name: string
  email: string
  
  // relations
  posts: ObjectId[] | Post[]
}

type Post = {
  _id: ObjectId  
  title: string
  createdAt: Date
  
  // relations
  author: ObjectId | User
  comments: ObjectId[] | Comment[]
}

type Comment = {
  _id: ObjectId
  text: string

  // relations
  user: ObjectId | User
  post: ObjectId | Post
}

今回は型のお話なのでmongooseのスキーマ定義は省略しますが、素のmongooseを利用すると以下のような呼び出しをします。

const comment = await Comment.findOne({ _id: 1 })
  .select(['text', 'post'])
  .populate({ path: 'user', select: ['name'] })

得られる実際のデータは以下のようなオブジェクトですが…

{ text: '面白かったです', post: ObjectId('67569d3d8d32177645326ed0'), user: { name: '坂本' } }

型はこうなっています

{ _id: ObjectId; text: string; user: ObjectId | User; post: ObjectId | Post }

// 本当はこのようになって欲しい
{ text: string; post: ObjectId; user: { name: string } }

今回はこの型解決を実現していきます。mongooseのようにパイプで繋いで型を生成していくのは面倒そうなので、まずは書き方を変えてしまいましょう。

Prismaを参考に、オブジェクトで諸々を指定する形にします。

※こんな感じでできたら良いなのイメージ

const commet = await Commenf.findOne({ _id: 1 }, {
  select: ['text', 'post'],
  populate: {
    user: {
      select: ['name']
    },
  }
})

第二引数にpopulateとselectを同時に受け取ります

ここの型定義は以下のようになっています。

populateはPopulateOptionが再帰的に呼び出されていて元々のmongooseと同様無限にpopulateを繋ぐことが可能になっています。

また、配列かどうかを区別する処理も入れています

type PopulateOption<T, K extends keyof T> = {
  select?: T[K] extends Array<any>
    ? (keyof Exclude<T[K][number], ObjectId>)[]
    : (keyof Exclude<T[K], ObjectId>)[]
  populate?: T[K] extends Array<any>
    ? PopulateParam<Exclude<T[K][number], ObjectId>>
    : PopulateParam<Exclude<T[K], ObjectId>>
}

type PopulateParam<T> = {
  [K in PopulatableFields<T>]?: PopulateOption<T, K>
}

これで引数の型は定義できましたが、今回欲しいのは返り値の型です。

GetResultåž‹

まずは一番外側の型である GetResult 型を定義していきます。

処理は3つの要素に分解できます。

元の型

{
  _id: ObjectId
  text: string
  user: ObjectId | User
  post: ObjectId | Post
}
  1. select する
{
  text: string
  post: ObjectId | Post
}
  1. ただselectした場合はpopulateされた型を削除する
{
  text: string
  post: ObjectId
}
  1. populate & select する(selectは1を再帰的に利用します)
{
  text: string
  post: ObjectId
  user: { name: string }
}

2 と 1 + 3 の部分に分けて以下のように作成します。

type GetResult<
  T,
  P extends PopulateParam<T>,
  S extends keyof T = keyof T,
> = RemovePopulatedType<Omit<Pick<T, S>, keyof P>> & _PopulatedDocument<T, P>

RemovePopulatedType

ObjectId | Post を ObjectId にする型を作ります。populate可能なフィールドだがpopulateせずにselectしただけの項目に対して利用します。

型の名前通り「ObjectId以外を排除する」ように実装すると複雑になるため、「populate可能な項目であればObjectIdに固定する」実装にしています。

mongooseのスキーマ定義を利用していれば基本的に _id: ObjectId という項目があるため、それを目印にして「populate可能」を判定しています( PopulatableFields )

type RemovePopulatedType<T> = {
  [K2 in PopulatableFields<T>]-?: T[K2] extends Array<any>
    ? ObjectId[]
    : ObjectId
} & {
  [K in Exclude<keyof T, PopulatableFields<T>>]-?: T[K]
}

/**
 * populate可能かどうかを _id があるかを基準に判定する
 */
type PopulatableFields<T> = Exclude<
  {
    [K in keyof T]: T[K] extends { _id: ObjectId } | { _id: ObjectId }[]
      ? K
      : never
  }[keyof T],
  '_id'
>

_PopulatedDocument<T, P>

ここからがメインディッシュです。populateとして指定された項目を再帰的に処理していきます。

今回の例ですと深さ1のpopulateしかありませんが、mongooseは再帰的にpopulateの指定が可能になっています。

例)

await Comment.findOne()
 .populate({
   path: 'post',
   populate: {
     path: 'author',
     populate: { ... }
   }
 })
 
// { post: { author: { ... } } }

2つの型 _PopulatedDocument と _PopulatedDocumentField を相互に呼び出し合って型を解決する形になっており、補助的な型として IsStrictlyEmptyObject があります。

2つの形に分けている理由は配列かどうかの分岐とselectせずまるっとpopulateだけする際の分岐を再利用したかったためです。おそらく1つの型で書くことも可能だと思います(可読性激下がりだとは思います)

/**
 * populateされたドキュメントの型を取得する
 * _PopulatedDocumentField と相互に再帰している
 * ```ts
 * type Comment = {
 *  _id: ObjectId
 *  post: ObjectId | Post // ここをpopulate & selectした状態にするための型
 * }
 * ```
 *
 */
type _PopulatedDocument<T, P extends PopulateParam<T>> = {
  [K in keyof P]-?: K extends keyof T
    ? // populate: { post: {} } など、丸っと populate する場合
      IsStrictlyEmptyObject<P[K]> extends true
      ? T[K] extends any[]
        ? RemovePopulatedType<Exclude<T[K][number], ObjectId>>[]
        : RemovePopulatedType<Exclude<T[K], ObjectId>>
      : T[K] extends any[]
        ? _PopulatedDocumentField<T[K][number], P[K]>[]
        : _PopulatedDocumentField<T[K], P[K]>
    : never
}

type IsStrictlyEmptyObject<T> = keyof T extends never
  ? {} extends T
    ? true
    : false
  : false

/**
 * populateされたフィールドの型を取得する
 * _PopulatedDocumentと相互に再帰している
 *
 * ```ts
 * type Comment = {
 *   _id: ObjectId
 *   // ここを { title: string, author: { ..recurrsive } } にする型
 *   post: ObjectId | Post
 * }
 * ```
 */
type _PopulatedDocumentField<T, P> = P extends {
  select: infer S
  populate?: infer SubPopulate
}
  ? S extends (keyof Exclude<T, ObjectId>)[]
    ? // ObjectId | Post → Post とし、 select 指定のフィールドに絞り込む
      RemovePopulatedType<
        Pick<Exclude<T, ObjectId>, Exclude<S[number], keyof SubPopulate>>
      > &
        // ネストされた populate がある場合は再帰的に処理する
        (SubPopulate extends PopulateParam<Exclude<T, ObjectId>>
          ? _PopulatedDocument<
              Pick<
                Exclude<T, ObjectId>,
                Extract<
                  PopulatableFields<Exclude<T, ObjectId>>,
                  keyof SubPopulate
                >
              >,
              SubPopulate
            >
          : {})
    : never
  : P extends { populate: infer SubPopulate2 }
    ? Pick<
        Exclude<T, ObjectId>,
        Exclude<keyof Exclude<T, ObjectId>, keyof SubPopulate2>
      > &
        // ネストされた populate がある場合は再帰的に処理する
        (SubPopulate2 extends PopulateParam<Exclude<T, ObjectId>>
          ? _PopulatedDocument<
              Pick<
                Exclude<T, ObjectId>,
                Extract<
                  PopulatableFields<Exclude<T, ObjectId>>,
                  keyof SubPopulate2
                >
              >,
              SubPopulate2
            >
          : {})
    : T extends ObjectId[]
      ? ObjectId[]
      : ObjectId

型を実装に利用する

作った実装を利用して以下のような関数を作ってあげると、いずれのスキーマに対しても利用可能です。

const findOneQuery = async <
  T,
  P extends PopulateParam<T>,
  S extends keyof T,
>(
  model: Model<T>,
  cond: FilterQuery<T>,
  options: {
    select?: S[]
    populate: P
  }
): Promise<GetResult<T, P, S>> => {
  // 指定されたオプションを実際のmongooseにいい感じにマッピングしてあげる
  const query = _pipeQueries<T, P, S>(model.findOne(cond), options)

  const result = await query.exec()

  return result as GetResult<T, P, S>
}
const comment = await findOneQuery(
  Comment,
  { _id: id },
  {
    select: ['text', 'post'],
    populate: {
      user: {
        select: ['name']
      },
    }
  }
)

//正しい型が取れました!
{ text: string; post: ObjectId; user: { name: string } }

Future Work

この形には以下のような制限があり、今後解決していけたらより強いmongooseの型が生成できると思っています

ネストされたフィールドに対するpopulateに未対応

mongodbはかなり柔軟なスキーマを持つことができるため、以下のように本来別テーブルするような情報でも直接持つことが簡単にできます。これに対してmongooseは 'logs.user' を指定してpopulate可能ですが、今回の型では対応できていません。

type Post = {
  logs: {
    action: 'edit' | 'create' | ...
    user: ObjectId | User
  }[]
}

Virtualやその他の細かな対応

mongooseはORMとしてvirtualフィールドなど多彩なカスタマイズが可能になっており、その辺りを正確に拾い切ることはできていません。

型のレベルで拾い切るのは正直厳しく、そもそもその機能を利用しないなどの制限を課す方が現実的ではないかと感じています。

実際運用してきてそれがないと生きていけないようなレベルのものはなく、mongooseへの依存を強めて将来的な負債になる可能性の方が高いと感じています。

まとめ

mongooseが対応していないpopulateやselectに対する型の自動生成に挑戦し、90%程度のユースケースには対応できる型が作れました。

今まで as ... でキャストをしなくてはならなかった型が自動で生成されることによりバグの可能性を減らし開発者体験をあげることができました。

対応できていないユースケースは今後もアップデートしていきたい一方で、あまりmongoose固有の機能に依存しない実装も大切であることも感じました。

最後に

ミツモアでは様々な職種のエンジニアを積極的に採用しています! ご興味がある方はぜひ気軽に面談しましょう!

プロダクト部では絶賛人材を募集しており、本記事のようなTypeScriptに強い人材を特に募集しています!他ポジションも多く募集中となっておりますので、坂本個人宛でも公式からでもどしどしご応募ください

募集一覧: https://hrmos.co/pages/meetsmore/jobs