Angularの@switchを@default neverで網羅検査するレビュー観点

判別可能なユニオン型を @switch で分岐させているテンプレートに、ユニオンの新しいメンバーが足されたとする。@case を足し忘れると、その値のときに何も描画されない。例外もログも出ない。

この取り逃がしを型検査で止める構文が @default never である。@angular/compiler 21.2.0 で入り、22 で引数を読むようになった。網羅していない分岐があればビルドが失敗する。

@angular/core 22.2.1 / 21.2.0 で入った構文
@switch (status.kind) {
  @case ('idle') { 

待機中

} @case ('loading') {

読み込み中

} @case ('done') {

完了

} @default never(status); }

書き方の制約と、網羅していないときに出るメッセージの読み方が、レビューで見る位置になる。とくに signal を呼ぶ式を @switch に書くと、網羅していても検査が失敗する。

型検査ブロックに入る1行

@default never が生成するのは、TypeScript の never への代入文1つである。テンプレート型検査ブロックを組み立てる TcbSwitchOp に、@case の並びのあとへ default 節を足す分岐がある。

@angular/compiler 22.2.1 の TcbSwitchOp.execute(抜粋)
if (this.block.exhaustiveCheck) {
  let translateExpression = this.block.expression;
  if (this.block.exhaustiveCheck.expression) {
    translateExpression = this.block.exhaustiveCheck.expression;
  }
  const switchValue = tcbExpression(translateExpression, this.tcb, this.scope);
  const exhaustiveId = this.tcb.allocateId();
  clauses.push(new TcbExpr(`default: const tcbExhaustive${exhaustiveId}: never = ${switchValue.print()};`));
}

つまり型検査ブロックには switch (status.kind) { case 'idle': … default: const tcbExhaustive1: never = status; } に相当するコードが並ぶ。TypeScript は default 節へ到達する値の型を狭めるので、すべての @case が揃っていれば代入先の型が never になって通る。足りなければ残ったメンバーが代入され、落ちる。

したがって出るのは NG で始まる Angular の診断ではなく、素の TypeScript のエラーである。21.2.0 以降の @angular/core と TypeScript 6.0 系で実際にビルドすると、残っているメンバーの型がそのまま文面に出る。

@case ('done') を落としたテンプレートの ngc 出力
app.ts:11:22 - error TS2322: Type '{ kind: "done"; value: string; }' is not assignable to type 'never'.

11       @default never(status);
                        ~~~~~~

文字列リテラルのユニオンを直接分岐させている場合は、足りないリテラルがそのまま出る。@switch の式に波線が付く。

kind: 'idle' | 'loading' | 'done' で @case ('done') を落とした場合
app.ts:5:14 - error TS2322: Type '"done"' is not assignable to type 'never'.

5     @switch (kind) {
               ~~~~

この検査は strictTemplates を有効にしていなくても走る。never への代入は型検査ブロックの中の普通の代入文であり、strictTemplates が変えるのは入力やイベントの照合の厳しさだからである。

構文として受け付けられない4つの形

@default never は本体を持てず、@switch の最後に置く必要がある。外れた形はテンプレートのパースエラー(NG5002)になる。22.2.1 の createSwitchBlock と validateSwitchBlock が出す文面は次の4つである。

NG5002 の文面(実際のビルド出力)
@default never { 

x

} → @default block with "never" parameter cannot have a body @default never; より後に @case → @default block with "never" parameter must be the last case in a switch 本体のない @case の直後に置く → A @case block with no body cannot be followed by a @default block with "never" parameter @default { … } と併記 → @switch block can only have one @default block

3つめは、本体のない @case を並べて1つの本体を共有させる書き方(21.1.0 で入った)との衝突である。@case ('a') @case ('b') { … } は2つの @case を1つのグループにまとめるので、@case ('c') の直後に @default never; を置くと 'c' を含むグループが閉じられない。このとき 'c' の分岐自体が @switch から落ちるため、NG5002 と並んで Type '"c"' is not assignable to type 'never' も出る。

4つめが設計の分かれ目である。網羅検査と、実行時の既定表示は同時に書けない。どちらを取るかは後の節で扱う。

末尾の ; は省けない。ブロック名を読む処理が default never を見たあと ; か { を待つので、どちらも無いと「不完全なブロック」として扱われる。

@default never の後ろに ; も { も無い場合
app.ts:7:7 - error NG5002: Incomplete block "default never". If you meant to write the @ character, you should use the "@" HTML entity instead.

signalを呼ぶ式で検査が失敗する理由

never への代入が通るかどうかは、TypeScript が default 節で値の型を狭められるかどうかで決まる。狭められるのは、switch の式が参照(変数、プロパティの読み取り)の場合である。関数呼び出しの結果は狭められない。

signal をテンプレートで読むと、この条件から外れる。

網羅しているのに落ちるテンプレート
// kind = signal<'idle' | 'loading'>('idle')
@switch (kind()) {
  @case ('idle') { <p>待機中</p> }
  @case ('loading') { <p>読み込み中</p> }
  @default never;
}
上のテンプレートの ngc 出力
app.ts:5:14 - error TS2322: Type '"idle" | "loading"' is not assignable to type 'never'.
  Type '"idle"' is not assignable to type 'never'.

5     @switch (kind()) {
               ~~~~~~

@case は2つとも揃っているが、kind() は毎回別の値を返しうる式なので、TypeScript は default 節で型を狭めない。ユニオンがそのまま残り、代入が落ちる。

エラーに出る型を見れば、どちらの失敗かが分かる。ユニオンの全メンバーが並んでいれば型の絞り込みが起きていない。1つだけ並んでいれば、その分岐が本当に足りていない。

絞り込みを起こすには、signal の読み取りを @let で受けて参照にする。

@let で受けた場合(検査が通る)
@let k = kind();
@switch (k) {
  @case ('idle') { <p>待機中</p> }
  @case ('loading') { <p>読み込み中</p> }
  @default never;
}

この差は @default never に限った話ではない。@switch (status().kind) と書くと、@case ('done') の本体でも status().value が読めない。絞り込みの対象が status() の呼び出し結果ではなく、呼び出しごとに別の値として扱われるためである。

@switch (status().kind) の @case ('done') で value を読んだ場合
app.ts:7:39 - error TS2339: Property 'value' does not exist on type 'Status'.
  Property 'value' does not exist on type '{ kind: "idle"; }'.

signal を @switch に渡している箇所は、@default never を足す前に @let へ移す。先に移しておけば、網羅検査の失敗が「分岐の取り逃がし」だけを指すようになる。

Comment
@Reviewer: `@switch (state().kind)` の形だと、`@default never` の検査が常に失敗します(関数呼び出しの結果には型の絞り込みが起きないため、ユニオンが丸ごと残ります)。`@let s = state();` で受けてから `@switch (s.kind)` に変えてください。`@case` の本体で `s.value` のような分岐固有のプロパティも読めるようになります。

never の括弧に何を渡すか

判別可能なユニオンをオブジェクトのまま持っている場合、@default never; と括弧なしで書くと、網羅していてもエラーが2つ出る。

@switch (status.kind) に @default never; を置いた場合(網羅済み)
app.ts:8:14 - error TS2322: Type 'any' is not assignable to type 'never'.
app.ts:8:21 - error TS2339: Property 'kind' does not exist on type 'never'.

括弧を省くと、代入される式は @switch の式、つまり status.kind になる。ところが default 節では status 自体が never に狭まっているため、そこから .kind を読む式が成り立たない。括弧に status を渡すと、狭まった結果をそのまま代入する形になって通る。

判別のためのプロパティで分岐する場合の書き方
@switch (status.kind) { … @default never(status); }

括弧の中身が読まれるのは 22 からである。21.2 系の TcbSwitchOp は this.block.expression だけを使い、SwitchExhaustiveCheck は式を持たない構造になっている。パースの側も default never に付いたパラメーターを拒否しないので、21.2 のプロジェクトに @default never(status) と書くと、括弧は無言で捨てられて status.kind が代入される。22 へ上げたときに、同じテンプレートの検査結果が変わる。

書き分けの基準は、@switch に渡した式が判別のためのプロパティかどうかである。文字列のユニオンを直接分岐させているなら括弧は要らない。オブジェクトのプロパティで分岐しているなら、オブジェクトを渡す。

網羅検査と既定表示のどちらを置くか

@default never は実行時に何も生まない。生成されるテンプレート関数は、@default を書かなかった場合と同一である。

JIT で取り出したテンプレート関数(@default never; 有りと無しで同一)
function _Template(rf, ctx) {
  if (rf & 1) { conditionalCreate(0, _Case_0_Template, 2, 0, 'p'); }
  if (rf & 2) { var tmp_0_0; conditional((((tmp_0_0 = ctx.kind) === 'a') ? 0 : -1)); }
}

分岐の添字が -1 になる経路が残っているので、ユニオンの外の値が来れば描画は空になる。開発モードで動かしても、警告は出ない。

jsdom 上で bootstrapApplication して値を差し替えた結果
idle      : "

idle

" loading : "

loading

" archived : ""

3行目は、型の上では存在しない kind: 'archived' を signal へ入れた場合である。例外もログも無い。型検査を通ったコードでこの状態になるのは、値がアプリの外から来るときである。API の応答を型アサーションでユニオンへ落としている箇所、localStorage から読んだ文字列、URL のクエリパラメーターがそれに当たる。

ここから、どちらを置くかの基準が決まる。値の生成元がアプリの中(自前のリデューサー、signal の更新、フォームの選択肢)に限られているなら、@default never を置いて分岐の追加漏れをビルドで止める。値が外から来るなら、実行時の既定表示が要るので @default を置き、ユニオンへ落とす側に型ガードを置いて、知らない値を弾くか既定値へ寄せる。

両方が要る場合、@switch の中では解けない。@default ブロックは1つしか置けないためである。外から来た値を型ガードで自前のユニオンへ変換する層を1つ作り、テンプレート側は変換後の型だけを見る形にする。

Comment
@Reviewer: この `@switch` の値は API の応答をそのまま `as Status` でユニオンへ落としたものなので、`@default never` で網羅を固定すると、サーバー側にステータスが足されたときに画面が空になります。応答を変換する箇所に型ガードを置いて、知らない値を `'unknown'` へ寄せてください。そのうえで `@case ('unknown')` を書けば、網羅検査と既定表示の両方が成り立ちます。

既存の @switch に足すときの順序

すでに @default を持つ @switch に網羅検査を入れるなら、@default の中身を見るところから始める。「ここには来ない」と書かれたコメントだけが置かれているなら、@default never に置き換えられる。エラー表示や既定値の描画が入っているなら、前節の基準で残す。

@default が無い @switch は、足した時点で検査が走るので、落ちたエラーの型を見て切り分ける。全メンバーが並んでいれば @let で受け直す。1つだけなら分岐を足す。Property 'kind' does not exist on type 'never' が出ていれば、括弧にオブジェクトを渡す。

テンプレートの制御フローで型と実行時の扱いが分かれる例は、?. の結果にもある。22 で null から undefined へ変わった影響はAngular 22で?.の結果がundefinedになった影響をレビューで追うにまとめた。@for の track で行の同一性が崩れる話はAngularの@forのtrack指定が招く入力値の取り違えをレビューで防ぐにある。

レビュー観点チェックリスト

@switch の網羅検査を確かめる項目
  • 判別可能なユニオンを分岐する @switch に、@default も @default never も無い箇所がないか
  • @default の中身が「ここには来ない」というコメントだけになっていないか
  • @switch の式が signal の呼び出しのままになっていないか(@let で受けているか)
  • 網羅検査が落ちたとき、エラーの型がユニオンの全メンバーを並べていないか(並んでいれば絞り込みが起きていない)
  • オブジェクトのプロパティで分岐している @switch で、never の括弧にオブジェクトを渡しているか
  • 21.2 系から 22 へ上げたプロジェクトで、never(…) の括弧が無視されていた箇所の検査結果が変わっていないか
  • 本体のない @case を並べたグループの直後に @default never; を置いていないか
  • @switch の値がアプリの外(API、localStorage、クエリパラメーター)から来ていないか
  • 外から来る場合、ユニオンへ落とす箇所に型ガードがあるか
  • 既定表示と網羅検査の両方が要る箇所で、@case として明示した「不明」の分岐を持っているか
  • @default never を足したあとに @case を削った差分で、ビルドの失敗を確かめているか

おわりに

@default never が生成するのは、型検査ブロックの default 節に置かれる never への代入1行である。出るエラーは TypeScript のもので、代入される型に残ったメンバーが文面へ出る。全メンバーが並んでいれば絞り込みが起きておらず、1つだけなら分岐が足りていない。

signal を @switch に渡している箇所では、絞り込みが起きない。@let で受けてから分岐する形に変えると、網羅検査も @case の本体でのプロパティ参照も通る。オブジェクトのプロパティで分岐している場合は、never の括弧にオブジェクトを渡す。括弧の中身を読むのは 22 からで、21.2 系では無言で捨てられる。

実行時には何も生まれない。ユニオンの外の値が来れば描画が空になるだけで、警告も出ない。値がアプリの外から来る @switch では、網羅検査より先にユニオンへ落とす箇所の型ガードを置く。テンプレート制御フローの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「テンプレート制御フロー」の群にある。