TypeORMで型安全にデータを取得するために: TypeORM-Strict-Typeの紹介

:::note info
以下のZenn記事にも同一の内容を投稿しています
https://zenn.dev/stringthread/articles/c39887d65b967f
:::

## はじめに

先日作成した「TypeORM-Strict-Type」というnpmライブラリをご紹介します。

https://www.npmjs.com/package/typeorm-strict-type

JavaScriptの著名なORマッパーであるTypeORMとあわせて使用することで、データベースから取得された値に付けられる型を安全なものにすることが可能になります。

## 解決する問題

### TypeORMの基本

TypeORMでは、`ManyToMany`などのデコレータを使うことによって複数テーブルの間のリレーションを定義できます。

```typescript
// TypeORM公式の例 ( https://typeorm.io/many-to-many-relations ) を一部改変

@Entity()
export class Category {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @ManyToMany(() => Question)
    questions: Question[]; // 外部リレーション
}

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

    @Column()
    title: string;

    @Column()
    text: string;

    @ManyToMany(() => Category)
    @JoinTable()
    category: Category[]; // 外部リレーション
}
```

こうした外部リレーションは、クエリビルダの`join`やリポジトリの`relations`オプションなどを使って、必要なものだけを指定することが一般的です。（以下はリポジトリを使った例）

```typescript
const dataLoader = new DataLoader({
  type: 'mysql',
});

const repository = dataLoader.getRepository(Category);

const categories = await repository.find({
  relations: { // ここで外部リレーションを指定する
    questions: true,
  },
});
```

### TypeORMの戻り値型の問題

しかし、上に書いた`repository.find`メソッドなどは、`relations`オプションを指定してもしなくても同じ型を戻り値として返します。そのため、本来は存在しないプロパティを参照していても型エラーが発生しません。

この挙動は以下のようなコードで問題となり、バグの温床となります。

```ts
const category = await repository.findOne(); // Category | null

if(category !== null) {
  console.log(category.questions); // 値はundefined
  type t = typeof category.questions; // 型はQuestion[]
  const questionTitles: string[] = category.questions.map(v => v.title); // ランタイムエラー!
}
```

`repository.findOne()`関数には`relations`オプションを指定していないため、`category.questions`は取得されません。しかし、取得関数の戻り値は無条件に`Category`型となるため、取得していない`category.questions`プロパティも存在すると見なされます。

そのため、上のコードの5行目
```ts
const questionTitles: string[] = category.questions.map(v => v.title); // ランタイムエラー!
```
はコンパイルエラーとならず、ランタイムで初めてエラーを発生します。

## 使い方

今回紹介する`TypeORM-Strict-Type`ライブラリは、`repository`のデータ取得関数に正しい戻り値型を自動推論させることで、上記の問題を解消します。

### インストール

npmにパッケージとして公開しています。`typeorm`を使用していれば、他の依存関係は必要ありません。

```sh
npm i typeorm-strict-type
```

### コードでの使い方

`TypeORM-Strict-Type`ライブラリを使用するには、以下の2つの作業を行います。

1. エンティティ定義で外部リレーションを`Relation<T>`型でラップする
1. リポジトリを`SafeRepository<Entity>`型にキャストする

```ts
import { Relation, SafeRepository } from 'typeorm-strict-type';

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

    @ManyToMany(() => Question)
    questions: Relation<Question>[]; // 1. 外部リレーションをラップ
}

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

    @ManyToMany(() => Category)
    @JoinTable()
    categories: Relation<Category>[]; // 1. 外部リレーションをラップ
}

const dataLoader = new DataLoader({
  type: 'mysql',
});

const repository = dataLoader.getRepository(
  Category
) as unknown as SafeRepository<Category>; // 2. リポジトリをキャスト
```

リポジトリを使う側には特別な対応は不要です。`relations`プロパティに対応して、自動的に適切な型が推論されます。

```ts
// キャスト後は普通に使用するだけ
const categories = await repository.find({
  relations: {
    questions: true,
  },
});
categories.questions; // OK

const categoryWithoutQuestions = await repository.findOne();
categoryWithoutQuestions.questions; // 型エラー
```

### リポジトリを使わない派の方へ: StrictEntity型でリレーションを絞り込む

このライブラリは、`StrictEntity<Entity, Relations>`という型も提供しています。これは、型引数に指定した外部リレーションだけを持つエンティティを作る型です。

これを使うと、たとえばデータ取得関数の戻り値に型を付けることが楽になります。

```ts
// リレーションなし
function fetchCategories(): Promise<StrictEntity<Category>[]> {
  return dataSource
    .manager
    .createQueryBuilder(Category, 'category')
    .getMany();
}
const categories = await fetchCategories();
categories[0].id; // リレーション以外はOK
categories[0].questions; // 型エラー

// リレーションあり
function fetchQuestion(id: number): Promise<StrictEntity<Question, "categories">> {
  return dataSource
    .manager
    .createQueryBuilder(Question, 'question')
    .innerJoinAndSelect("question.categories", "categories")
    .getOne();
}
const question = await fetchQuestion(1);
question.categories; // OK
```

1階層であれば`Pick`や`Omit`を駆使して型を付けることが可能だと思いますが、`StrictEntity<Entity, Relations>`はネストが深くなったときや配列が含まれるときに本領を発揮します。

`StrictEntity<Entity, Relations>`は

- `Relation`型でないものはデフォルトで選択済み
- ドット区切り文字列で深い階層の絞り込みが可能
- unionで複数プロパティを一括で指定可能
- 配列や`Promise`の内部も絞り込みが可能

という強力な特性を持っています。

```ts
class A {
  id: number;
}
class B {
  id: number;
  a1: Relation<A>;
  a2: Relation<A>;
}
class C {
  id: number;
  b1Array: Relation<B>[];
  b2Array: Relation<B>[];
}

// 複数階層はdot区切り
// unionで複数指定
type t = StrictEntity<C, 'b1Array.a1' | 'b2Array'>;
type test1 = t.b1Array.a1; // OK
type test2 = t.b1Array.a2; // NG
type test3 = t.b2Array.id; // OK
type test4 = t.b2Array.a1; // NG
```

## おわりに

Entity上のアノテーションとリポジトリのキャストだけで、TypeORMの型安全なリレーション取得を可能にするライブラリ`TypeORM-Strict-Type`をご紹介しました。

このライブラリは鋭意開発中で、現在はリポジトリパターンのみをサポートしていますが、他のデータ取得方式や高度なTypeORMのオプションへ対応するなどの進化を考えています。

バグや機能リクエストなどがあれば、以下のGitHubにissueとしてお送りください。Qiitaでのコメントも歓迎しております！

https://github.com/stringthread/typeorm-strict-type

なお、このライブラリは、以下の記事のアイデアをもとに作成されました。末筆ながら感謝を申し上げます。
[TypeORMのData Mapperパターンにおけるリレーションの型安全性を担保する](https://tech.mobilefactory.jp/entry/2023/09/06/160000)

## おまけ（はじめてのライブラリ公開）

余談ですが、こちらは筆者にとって初めてのライブラリ公開でした。

npmへの公開には高いハードルを感じていましたが、公開プロセスは思っていたより数倍簡単でしたし、1週間で100を超えるDLをいただくことができました。

自作ライブラリのアイデアがある方は、とりあえずnpmなどにアップロードしてみてはいかがでしょうか。

このライブラリを公開するにあたって、以下のサイトを参考にしました。

https://qiita.com/__mick/items/e92e3fc001994ca9dfaf

https://zenn.dev/suin/scraps/9f454023cefb67