AngularコアチームのStephen Fluin氏が、こんなブログ記事をあげている。
https://fluin.io/blog/property-has-no-initializer-and-is-not-definitely-assigned
TypeScript 2.7から導入された、クラスプロパティの初期化をチェックするstrictPropertyInitialization
オプションの話だ。
tsconfigのstrictPropertyInitialization
オプションを有効にすると、undefinedを許容していないプロパティがプロパティ宣言時あるいはコンストラクタで初期化されていないときにコンパイルエラーになる。
これをstrictNullChecks
オプションと併用することで、明示的に T?
あるいは T | undefined
という宣言をしない限りかならず初期化を要求される。
たとえば次のようなコードがエラーになる。name
プロパティはstring
型なのでundefined
を許容せず、初期化漏れのコンパイルエラーになる。
class Person {
name: string;
constructor() {
}
}
この設定は安全なTypeScriptを書くうえでかなり便利だが、Angularにおいては少し注意が必要である。
AngularでstrictPropertyInitializationを使う上で問題になるのは、クラスプロパティのうちAngularのデコレーターによって遅延して初期化されるものだ。
たとえば、@ViewChild
や@ContentChildren
などは、クラスの初期化時ではなくコンポーネントのビューツリーの解決時に初期化されるので、strictPropertyInitializationがうまく噛み合わなくなる。
そのため、ビュー解決後は値を持っていることはほぼ確実だが、それまではundefinedになるので、プロパティを?
としてオプショナルにするか、| undefined
としてundefined
を許容することになる。
Stephenのベストプラクティス
AngularコアチームのStephenは、TypeScriptにしたがい、ビュー解決を待つプロパティは?
でオプショナルにするのを推奨している。
理由は書かれていないが、推測するとコンポーネントのクラス実装とテンプレートは文字列あるいは別ファイルに存在した疎結合の関係であり、開発者の頭の中では確実に存在するとわかっていても、システム上は実行するまでViewChild
で取得しようとしている子のビューが存在することは不定である。
次のように、child
は基本的にオプショナルであり、存在が確認できるときだけ処理をするのがベストである。なぜならngIfによるスイッチングなど、コンポーネントの生存中に子ビューの参照が消えることは多々あるからだ。
class SomeComponent {
@ViewChild() child?: SomeChildComponent;
ngAfterViewInit() {
if (this.child != null) {
}
}
}
Inputプロパティについてのプラクティス
Angularで初期化が問題となるプロパティデコレーターのもうひとつは、@Input
デコレーターだ。
現実問題として、Inputにはオプショナルなものと必須なものがある。常に特定のInputが与えられることを前提として記述されるコンポーネントだ。たとえば次のような例が考えられる。
@Component({
selector: 'user-card'
})
class UserCardComponent {
@Input() user: User;
}
このコンポーネントで、 user
をオプショナルにするのは意味論的に避けたいし、契約としてそういったコンポーネントの利用は禁止したい。そのためuser
の型はNon-NullableなUser
型である。
しかし、これをこのまま放置すると、strictPropertyInitializationオプションで初期化していないとエラーになる。
この問題について尋ねると、別のAngularコアチームメンバーであるRado Kirov氏からアドバイスをもらえた。
少し乱暴ではあるが、契約として必ず値が渡されることを求めるプロパティについては、Non-nullアサーションオペレータ !
を使ってプロパティがundefinedじゃないことを明示的に示せばいいというものだ。
次のようなコードになる。プロパティの宣言時には必ず初期化されていることを明示し、コンポーネントの初期化後にはそれを確認する。
?
を使ったものと違い、プロパティの型をオプショナルにしていないので、プロパティを使用するたびにif文で型ガードを作らなくてもよい。
親からの値が必須であるInputプロパティについては、実行時アサーションとセットにしたNon-nullアサーションオペレータで解決するのが、現状のベストプラクティスになりそうだ。
@Component({
selector: 'user-card'
})
class UserCardComponent {
@Input() user!: User;
ngOnInit() {
if (this.user == null) {
throw new Error('[user] is required');
}
this.someFunc(this.user);
}
someFunc(user: User) {}
}
将来的にはcodelyzerやlanguage-serviceでこのへんをチェックして、undefinedを許容していないInputへの値渡しがテンプレート中で行われていないことを検知してもらいたい。
Observableの初期化
Storeとの接続や、リアルタイムDBとの接続など、コンポーネントがObservableを購読する必要があるときは、コンストラクタでObservableの初期化をおこなうのがよい。
よくあるRedux的な状態管理をしているアプリケーションだと、このようにコンポーネントとストアを接続する。
そしてコンポーネント内ではsubscribe
せず、テンプレート内でasync
パイプを使って非同期ビューを構築する。
class UserListComponent {
userList$: Observable<User[]>;
constructor(store: Store) {
this.userList$ = this.store.select(state => state.userList);
}
}
コンポーネント内でsubscribe
する必要がある場合は、Observableの初期化だけをコンストラクタで行い、subscribe
の開始はngOnInit
以降に開始すべきである。
コンストラクタでsubscribe
してしまうと、コンポーネントの初期化より先に値の解決が始まってしまい、変更検知のタイミング制御が困難なり、デバッグしにくくなる。
まとめ
@ViewChild
や@ContentChild
はオプショナルプロパティとして扱うべし
- 必ず親から値を渡されないと困る
@Input
は、実行時アサーションとセットでNon-nullアサーションオペレータを使うべし
- Observableのプロパティ初期化はコンストラクタで行い、
subscribe
はasync
パイプあるいはngOnInit
以降にAngularのライフサイクルにあわせて開始するべし