Marginalia

AngularにおけるstrictPropertyInitializationのベストプラクティス

Angular コアチームの Stephen Fluin 氏が、こんなブログ記事をあげている。

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デコレーターだ。

現実問題として、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); // 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 のプロパティ初期化はコンストラクタで行い、subscribeasyncパイプあるいはngOnInit以降に Angular のライフサイクルにあわせて開始するべし