以下のZenn記事にも同一の内容を投稿しています
https://zenn.dev/stringthread/articles/c39887d65b967f
はじめに
先日作成した「TypeORM-Strict-Type」というnpmライブラリをご紹介します。
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つの作業を行います。
- エンティティ定義で外部リレーションを
Relation<T>
型でラップする - リポジトリを
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階層であればPick
やOmit
を駆使して型を付けることが可能だと思いますが、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としてお送りください。Qiitaでのコメントも歓迎しております!
なお、このライブラリは、以下の記事のアイデアをもとに作成されました。末筆ながら感謝を申し上げます。
TypeORMのData Mapperパターンにおけるリレーションの型安全性を担保する
おまけ(はじめてのライブラリ公開)
余談ですが、こちらは筆者にとって初めてのライブラリ公開でした。
npmへの公開には高いハードルを感じていましたが、公開プロセスは思っていたより数倍簡単でしたし、1週間で100を超えるDLをいただくことができました。
自作ライブラリのアイデアがある方は、とりあえずnpmなどにアップロードしてみてはいかがでしょうか。
このライブラリを公開するにあたって、以下のサイトを参考にしました。