Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

TypeORMのData Mapperパターンにおけるリレーションの型安全性を担保する

こんにちは!BC チームでエンジニアをしている id:d-kimuson です。

今回は外部リレーションに関して型安全性の乏しい TypeORM の Data Mapper パターンを独自のユーティリティ型を使ってちょっとマシにする方法を紹介します。

前提: TypeORM の外部リレーションについて

TypeORM では ManyToMany 等のデコレータを使ってスキーマに Foreign Key を書くことができます。

// 公式ドキュメントのサンプルです
@Entity()
export class Category {
  @PrimaryGeneratedColumn()
  id: number

  @Column()
  name: string

  @ManyToMany((type) => Question, (question) => question.categories)
  questions: Question[]
}

@Entity()
export class Question {
  @PrimaryGeneratedColumn()
  id: number

  @Column()
  title: string

  @Column()
  text: string

  @ManyToMany((type) => Category, (category) => category.questions)
  @JoinTable()
  categories: Category[]
}

そして、実際にデータをクエリビルダやリポジトリのメソッドから取ってくるに当たって、外部リレーションを一緒に取ってくるには複数のやり方がサポートされています。

① クエリレベルでリレーションを選択する

最もオーソドックスなやり方でクエリを叩くときにリレーションを選択します。

declare const repository: Repository<Question>

// リポジトリメソッド
const question = await repository.findOneOrFail({
  relations: ["categories"],
  where: { id: 1 },
}) // relation に指定した categories は自動的に Join されて取得されます

// クエリビルダ
const question = await repository
  .createQueryBuilder("question")
  .innerJoinAndSelect("question.categories", "categories")
  .where("question.id = :id", { id: 1 })
  .getOneOrFail() // joinAndSelect に指定した categories は自動的に Join されて取得されます

innerJoinAndSelectrelation によって明示することでリレーションのあるレコードを拾ってきてくれます。

② スキーマレベルで eager: true を設定し、選択しなくても勝手に Join させる

@Entity()
export class Question {
  // ...
  @ManyToMany((type) => Category, (category) => category.questions, {
    eager: true,
  })
  @JoinTable()
  categories: Category[]
}

eager: true を指定しておくと relations に指定せずとも自動的に Join されて取得されます。

// リポジトリメソッド
const question = await repository.findOneOrFail({
  // relations: ['categories'], // なくても自動的に JOIN される
  where: { id: 1 },
})

// クエリビルダ
const question = await repository
  .createQueryBuilder("question")
  // .innerJoinAndSelect('question.categories', 'categories') // なくても自動的に JOIN される
  .where("question.id = :id", { id: 1 })
  .getOneOrFail()

明示せずとも Join して取ってこれるので便利ですが、必要ないケースでも必ずリレーションを取ってきてしまうという欠点があります。

③ スキーマレベルで lazy relation を設定し、参照時にクエリを発行する

@Entity()
export class Question {
  // ...

  @ManyToMany((type) => Category, (category) => category.questions)
  @JoinTable()
  categories: Promise<Category[]>
}

スキーマで Promise でリレーションを宣言すると、lazy relation となります。 参照したタイミングでクエリが走り、await してあげることでリレーション先のデータを取得できます。

declare const question: Question

await question.categories // 参照したタイミングでクエリが発行されて取得できる

外部リレーションを取得する方法としては、以上の 3 種類が存在します。

弊チームでは

  • ② Eager Relation: 必要ないユースケースでもリレーションを取ってくることになってしまうため基本使わない
  • ③ Lazy Relation: 弊チームでは TypeORM をリポジトリパターンとして利用しているので、参照時にクエリが発行される Lazy Relation のアプローチは取りたくない

ことが理由で基本的には ① のアプローチのみを利用しています。 この記事は ① のアプローチを使用している(eager relation や lazy relation を利用しない)ことが前提となります。

問題: 都度リレーション指定だと型安全性がない

② や ③ のアプローチで外部リレーションを取ってきている場合や、Active Record パターンを使っている場合は都度フェッチしたり必ず Join されていたりするので問題にならないのですが、Data Mapper で ① の都度 Join の利用だと、外部リレーションに関して型安全性の問題があります。

これは Join するかどうかがクエリビルダやリポジトリメソッドのオプションの指定に左右されますが、返されるデータの型はスキーマクラスの型で固定になってしまうからです。

例えば

const question = await repository
  .createQueryBuilder("question")
  .innerJoinAndSelect("question.categories", "categories")
  .where("question.id = :id", { id: 1 })
  .getOneOrFail()

question.categories // Categories[] 型になっていて、実際にアクセスもできる

のように使うリレーションが Join 済みであれば問題ありませんが

const question = await repository
  .createQueryBuilder("question")
  .where("question.id = :id", { id: 1 })
  .getOneOrFail()

question.categories // Categories[] 型になってるのに、アクセスすると undefined を受け取ってしまう

このように Join がされていない場合には型が存在するので取得済みだと思ったデータにアクセスすると実際には undefined を受け取ってしまうことになります。

これだと例えば question を引数に取るサービスメソッドを用意したときに Join なしで取得していても、Join が前提になるメソッドへ渡せてしまうことになります。

const question = await repository
  .createQueryBuilder("question")
  .where("question.id = :id", { id: 1 })
  .getOneOrFail() // categories は拾ってきていない

const someMethod = (question: Question) => {
  // categories を使ってゴニョゴニョする
  const x = question.categories.filter(...)  // Uncaught TypeError: Cannot read properties of undefined (reading 'filter')
}

someMethod(question) // 渡せてしまう...

この問題があるため以前は question: Question // categories の Join が前提 みたいなコメントを使って意図を伝えるようにしており、辛い状態でした。

ユーティリティ型で解決する

この問題をちゃんと型チェックで気付けるようにする回避策として独自の型ユーテリティを用意しています。 本当はライブラリ側で良い感じに吸収してくれて型が付くと嬉しいのですが、現状ではできていないので独自のユーティリティ型を用意してデータフェッチ時に型を書くことで対応しています。

アプローチとしては

  • スキーマクラス型からリレーションについて型安全な型に変換するユーティリティ型(StrictEntity)を用意し、データフェッチや制約として関数の引数で使うときに StrictEntity を通す
  • StrictEntity はスキーマクラスからリレーションのキーを除外する
    • リレーションのキーは string, number, bigint, boolean, Date 以外が値にあるもの
  • Join されている対象は Generics で明示的に指定する

のような形で実現しています。

実装は以下のようになってます。

type TypeOrmPrimitive = string | number | bigint | boolean | Date

export type PlainObject<Entity> = {
  [K in Exclude<keyof Entity, MethodKeys<Entity>>]: Entity[K]
}

/**
 * @desc TypeOrm の Entity からリレーションを持たないキーを取り出す Utility
 */
export type PrimitiveKeys<T> = keyof {
  [K in keyof PlainObject<T> as NonNullable<T[K]> extends TypeOrmPrimitive
    ? K
    : never]: T[K]
}

/**
 * @desc Class のメソッドのキーを抜き出す Utility
 */
export type MethodKeys<T> = keyof {
  [K in keyof T as T[K] extends Function ? K : never]: T[K]
}

/**
 * @desc TypeOrm の Entity から別テーブルへのリレーションのキーを抜き出す Utility
 */
type RelationKeys<T> = Exclude<keyof T, PrimitiveKeys<T> | MethodKeys<T>>

type OptionalKeys<T> = {
  [P in keyof T]-?: {} extends Pick<T, P> ? P : never
}[keyof T]

export type StrictEntity<
  T,
  JoinedRelationKeys extends RelationKeys<T> = never,
  RelationsOptionalKeys extends keyof T = Extract<
    JoinedRelationKeys,
    OptionalKeys<T>
  >,
  RelationsRequiredKeys extends keyof T = Exclude<
    JoinedRelationKeys,
    RelationsOptionalKeys
  >
> = Pick<T, PrimitiveKeys<T>> & {
  [K in RelationsOptionalKeys]?: T[K] extends undefined | infer I
    ? I extends Array<infer Item>
      ? ReadonlyArray<StrictEntity<Item>>
      : StrictEntity<I>
    : never
} & {
  [K in RelationsRequiredKeys]: T[K] extends Array<infer Item>
    ? ReadonlyArray<StrictEntity<Item>>
    : StrictEntity<T[K]>
}

読んでもらうより実際に例を見せるのが良いと思うので、まずはシンプルな例を提示します。

const question: StrictEntity<Question> = await repository
  .createQueryBuilder("question")
  .where("question.id = :id", { id: 1 })
  .getOneOrFail()

このように変数の型を明示的に StrictEntity<Question> で型付けをします。ここで StrictEntity<Question>Question 型の部分型(より制約が強い型)なので型注釈のみで変数に入れられます。

StrictEntity<Question> は外部リレーションのプロパティを取り除くので、実態としては

{
  id: number
  title: string
  text: string
  // categories: Category[] // 除外される
}

のような型として型付けされます。

また、リレーションが存在するときは

const question: StrictEntity<Question, "categories"> =
  await repository
    .createQueryBuilder("question")
    .innerJoinAndSelect("question.categories", "categories")
    .where("question.id = :id", { id: 1 })
    .getOneOrFail()

のように型付けをします。

型引数で categories を指定すると、今度は

interface {
  id: number
  title: string
  text: string
  categories: StrictEntity<Category>[]
}

こういう型に型付けされます。

StrictEntity<Question, "categories"> で型付けされているときに innerJoinAndSelect が呼ばれていることを保証することはできませんが、型安全でない範囲をこの部分だけに閉じることができるようになりました。これであれば目視での確認もしやすいですし、categories のリレーションのみ存在するという暗黙的な情報を型で表現できるようになりました。

データフェッチした変数がこれで型安全にできたので、サービスメソッドの引数を同じユーティリティを使った型安全にしていきます。問題点のところで紹介したメソッドの型付けを単に Question から必要なリレーションに限定した StrictEntity<Question, "categories"> へ変更します。

const someMethod = (question: StrictEntity<Question, "categories">) => {
  // categories を使ってゴニョゴニョする
  const x = question.categories.filter(...)
}

これにより

declare const question: StrictEntity<Question>

someMethod(question) // categories リレーションが必要なためちゃんと型エラーになる

Join されている情報が型で表現できるようになったため、必要なリレーションが足りない場合には型エラーが出てくれるようになりました。

これで、「Join せずに拾ってきたデータを誤って Join 前提の引数に渡してしまう」ようなミスを型レベルで防げるようになり、暗黙的にどのリレーションが Join されているかを意識する必要性がなくなりました。

まとめ

TypeORM における Eager / Lazy Relation を使わないときの型安全性のなさを型ユーテリティを使って回避する方法を紹介しました。

このやり方でもデータフェッチ時の型付けと Join の有無の整合性は開発者で担保する必要があり完全な解決策にはなりませんが、比較的手軽につらさを軽減できると思います。TypeORM を使っていてリレーションの型安全性にお困りの方はぜひお試しください!