🦺

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

2024/03/11に公開

はじめに

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

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

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

解決する問題

TypeORMの基本

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

// 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オプションなどを使って、必要なものだけを指定することが一般的です。(以下はリポジトリを使った例)

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

const repository = dataLoader.getRepository(Category);

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

TypeORMの戻り値型の問題

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

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

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行目

const questionTitles: string[] = category.questions.map(v => v.title); // ランタイムエラー!

はコンパイルエラーとならず、ランタイムで初めてエラーを発生します。

使い方

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

インストール

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

npm i typeorm-strict-type

コードでの使い方

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

  1. エンティティ定義で外部リレーションをRelation<T>型でラップする
  2. リポジトリをSafeRepository<Entity>型にキャストする
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プロパティに対応して、自動的に適切な型が推論されます。

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

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

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

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

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

// リレーションなし
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階層であればPickOmitを駆使して型を付けることが可能だと思いますが、StrictEntity<Entity, Relations>はネストが深くなったときや配列が含まれるときに本領を発揮します。

StrictEntity<Entity, Relations>

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

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

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としてお送りください。Zennでのコメントも歓迎しております!

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

なお、このライブラリは、以下の記事のアイデアをもとに作成されました。末筆ながら感謝を申し上げます。
TypeORMのData Mapperパターンにおけるリレーションの型安全性を担保する

おまけ(はじめてのライブラリ公開)

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

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

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

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

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

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

Discussion