はじめに
TypeScriptでのORM選択時の一大候補、TypeORM。
デコレーターでスキーマ定義、ActiveRecordスタイル、ダウンロード数。
そのどれもが魅力的である。
しかし、TypeORMにはいくつか落とし穴がある。
ここでは私が思い出せる限りの問題と、その対処法を並べていく。
Entity定義
問題
公式のサンプルのようにEntityを定義するとうまく行かない。
@Entity()
class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
idもnameもコンストラクタで初期化されていないため、VSCodeが赤線で怒ってくる。
対処法
@Entit()
class User extends BaseEntity {
@PrimaryGeneratedColumn()
- id: number;
+ id!: number;
@Column()
- name: string;
+ name!: string;
}
!
つけて回避できる。
create関数の引数型
問題
レコードを作るときの書き方は大体二通りだろう。
// newする。
const user = new User();
user.name = 'name';
await user.save();
// create関数を使用する。
await User.create({name: 'name'}).save();
注意すべきは、このどちらも、初期化漏れを許容することだ。
newする方は代入忘れを防ぐ方法がなく、create関数に渡す初期化子はPartialになっている。
それはつまり、
@Entity()
class User extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column()
name!: string;
+ @Column()
+ email!: string;
}
のように新しいカラムを足しても、型は警報を鳴らすことなくビルドが成功し、save()
時にnot null制約に引っかかるということだ。
対処法
二通りある。
コンストラクタ
@Entity()
class User extends BaseEntity {
constructor(name: string, email: string) {
super();
this.name = name;
this.email = email;
}
@PrimaryGeneratedColumn()
id!: number;
@Column()
name: string;
@Column()
email: string;
}
await new User('name', 'example@example.example').save();
これは上手くいくように見えるし、実際一定の条件下では上手くいく。
nameとemailの!
も取れているため、コンストラクタでの初期化忘れにも対応している。
しかし、console.log
を仕掛けてみるとわかるが、TypeORMはcreateConnection
時になんと、一度引数無しで強引にインスタンスを作っているのだ。
それはつまり、上記のコードは一応動くが、厳密にはundefinedが引数として渡されることがある、ということであり、それは、
@Entity()
class User extends BaseEntity {
constructor(props: {name: string, email: string}) {
super();
// ↓でアウト。
this.name = props.name;
this.email = props.email;
}
// ...
}
のように、オブジェクトを引数に取ると、createConnection
時にprops
がundefined
の状態で呼び出され、undefined.name
で死ぬということだ。
引数オブジェクトに対するアクセスは許されない。
これの対処法は、propsをoptionalにしてチェックをするということだが、これではコンストラクタでnameやemailを初期化していないケースが存在することになり、nameとemailに!
が舞い戻ることになる。
@Entity()
class User extends BaseEntity {
constructor(props?: {name: string, email: string}) {
super();
if (props == null) return;
this.name = props.name;
this.email = props.email;
}
@PrimaryGeneratedColumn()
id!: number;
@Column()
name!: string;
@Column()
email!: string;
}
そしてoptionalにするということは、
await new User().save();
ができるようになるということだ。
ぐぬぬ。
build関数を自作
TypeORMのcreate
が信用できなければ、ラッピングすれば良い。
@Entity()
class User extends BaseEntity {
static build = (props: {name: string, email: string}) => User.create(props);
// ...
}
await User.build({name: 'name', email: 'example@example.example'}).save();
当然だが、上手くいく。
定義が面倒だということ以外に目立った欠点はないだろう。
すべきだ。
関連
eager-and-lazy-relationsを使いさえすれば、この項目は無視できるかもしれない。
しかしパフォーマンスを気にしだすと途端に苦しくなる。
この項目ではUserがProfileを持っていると仮定する。
こんな具合だ。
@Entity()
class User extends BaseEntity {
@OneToOne(() => Profile)
readonly profile!: Profile;
}
型ずれ
問題
普通にfindした場合、
const user = await User.findOne()!;
user.profile;// type: Profile, real: undefined
関連は読み込まれないので、user.profile
はundefined
なのだが、型はProfile
だ。
言うまでもなく危険である。
対処法
これを回避するためには、
@Entity()
class User extends BaseEntity {
@OneToOne(() => Profile)
- readonly profile!: Profile;
+ readonly profile?: Profile;
}
のように、profileをoptionalにすれば良いのだが、それはそれで、
const user = await User.findOne({relations: ['profile']})!;
user.profile;// type: Profile | undefined, real: Profile
const userProfile = user.profile!;
のようになるため、読み込んだ後の使い勝手が悪いが、仕方ないだろう。
読み込み
問題
const user = await User.findOne({relations: ['profile']})!;
気をつけてほしい。
タイポは致命的だ。
relationsはstring[]だ。
relationsはstring[]なのだ。
それはつまり、こう書いてもコンパイルは通るということだ。
const users = await User.find({relations: ['プロフィール']});
ぐぬぬ。
対処法
わからない。
Webpack使用時の循環参照
問題
私はTypeORMをNext.jsと一緒に使っていたのだが、production環境の時だけエラーが出る事件があった。
結構な時間をかけて調査した結果、原因はこれだった。
TypeORMは関連を定義する時、関連先のEntityをimportする必要がある。
import {Profile} from './Profile';
@Entity()
class User extends BaseEntity {
@OneToOne(() => Profile)
readonly profile!: Profile;
}
それはUser.tsと同様に、Profile.tsでも必要だ。
そしてこれは循環importになり、使用しているビルドツールによってはサポートされていない。
理論上import
をimport type
に変更することで修正できるが、@OneToOne(() => Profile)
の行があるので、スムーズにはいけない。
対処法
-import {Profile} from './Profile';
+import type {Profile} from './Profile';
@Entity()
class User extends BaseEntity {
- @OneToOne(() => Profile)
+ @OneToOne('Profile')
readonly profile!: Profile;
}
@OneToOne
に渡すものは文字列でも良いため、これで問題ない。
しかし、文字列だ。
文字列なのだ。
ぐぬぬ。
TypeORMを使用するときは用心すべし
ざっと思い出せる罠はこれくらいだが、とどのつまり、TypeORMの型情報はあまり信用できない。
const user = User.create({name: 'name'});
user.id; // Type: number, real: undefined
await user.save();
user.id; // Type: number, real: number
const user2 = User.find(user.id, {select: ['id']});
user2.name; // Type: string, real: undefined
create後のautogenerateカラムの型ずれは仕方がないにしても、selectはよほどのことがなければ使わないほうが身のためだろう。
最後に
これらの問題は私自身の力量不足や知識不足でTypeORMの力をフルに引き出せず、正しく使えていなかったから引き起こされたという説もある。