AngularにおけるstrictPropertyInitializationのベストプラクティス
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; // Property 'name' has no initializer and is not definitely assigned in the constructor.
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
デコレーターだ。
Nice practices! How do you think about Inputs? is it optional?
— lacolaco👑 (@laco2net) June 26, 2018
現実問題として、Input にはオプショナルなものと必須なものがある。常に特定の Input が与えられることを前提として記述されるコンポーネントだ。たとえば次のような例が考えられる。
@Component({
selector: "user-card"
})
class UserCardComponent {
@Input() user: User;
}
このコンポーネントで、 user
をオプショナルにするのは意味論的に避けたいし、契約としてそういったコンポーネントの利用は禁止したい。そのためuser
の型は Non-Nullable なUser
型である。
しかし、これをこのまま放置すると、strictPropertyInitialization オプションで初期化していないとエラーになる。
この問題について尋ねると、別の Angular コアチームメンバーである Rado Kirov 氏からアドバイスをもらえた。
As far as the framework is concerned all inputs are optional, so you have to init them or threat them as such (w/ ?). If you want them to be required for component consumers use ! and add an assert in ngOnInit.
— Rado Kirov (@radokirov) June 27, 2018
Prop!: Foo
— Rado Kirov (@radokirov) June 27, 2018
ngOnInit() {
assert(https://t.co/NfXJzhlxCJ != undefined);
someFunc(this.prop);
}
少し乱暴ではあるが、契約として必ず値が渡されることを求めるプロパティについては、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); // no need `if` type guard
}
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 のライフサイクルにあわせて開始するべし
