余白

https://blog.lacolaco.net/ に移転しました

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

現実問題として、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のライフサイクルにあわせて開始するべし