Angular 22で?.の結果がundefinedになった影響をレビューで追う
Angular 22で?.の結果がundefinedになった影響をレビューで追う
Angular 22 のテンプレートで ?. を書くと、生成されるコードが JavaScript の optional chaining そのものになる。JIT でコンパイルした更新関数を取り出すと次のようになる。
function _Template(rf, ctx) {
if (rf & 1) { domElement(0, 'div', 0); }
if (rf & 2) { domProperty('title', ctx.user?.name); }
}21 までは三項演算子に展開され、受け手が nullish のとき null を返していた。22 ではネイティブの ?. になるので undefined が返る。変換を決めているのは safeTransform の分岐である。
const useNullSemantics = ctx.job.legacyOptionalChaining || (flags & VisitorContextFlag.InSafeNavigationMigration) !== 0;
if (!useNullSemantics) {
if (e instanceof SafePropertyReadExpr) {
return new ReadPropExpr(e.receiver, e.name, null, e.sourceSpan, [], true);
}
if (e instanceof SafeKeyedReadExpr) {
return new ReadKeyExpr(e.receiver, e.index, null, e.sourceSpan, [], true);
}
return e;
}LEGACY_OPTIONAL_CHAINING_DEFAULT は false なので、既定では上の分岐に入る。旧挙動が要るときは三項演算子の形に戻り、SafeTernaryExpr が ternaryTransform で (guard == null ? null : expr) になる。
この変更で壊れるのは、?. の結果を null と厳密に比較していた式である。@if (user?.name === null) は常に false になる。比較を == null か ?? に書き換えれば済む話だが、どこに比較が残っているかを探す前に、旧挙動が残る場所を押さえておく必要がある。
旧挙動が残る3つの位置
undefined に変わるのは、22 のコンパイラが新しくコンパイルしたテンプレートだけである。残り方が3通りある。
1つは legacyOptionalChaining を明示した場合である。angularCompilerOptions のオプションとして公開されている。
/**
* Whether to follow the Javascript optional chaining specs: returning `undefined` instead of
* `null` for null-safe navigation operations.
*
* Defaults to `false`.
*/
legacyOptionalChaining?: boolean;この doc コメントの説明は実装と逆である。safeTransform は legacyOptionalChaining が真のときに null を返す側を選ぶので、true にすると JavaScript の仕様から外れる。既定の false が仕様どおりの undefined である。オプション名のほうが実装と合っているので、名前で読む。
2つめは、21 以前で部分コンパイルされたライブラリである。Angular Linker はライブラリの宣言に書かれた版を見て、この値を決める。
legacyOptionalChaining: major < 22 && version !== PLACEHOLDER_VERSION,21 で publish された npm パッケージのコンポーネントは、22 のアプリに組み込んでも ?. が null を返す。アプリ側のテンプレートは undefined を返す。同じ記法の挙動がパッケージ単位で分かれるため、ライブラリの入力に渡す値の扱いは、呼ぶ側の挙動で考える。
3つめが移行 schematic である。@angular/core の migrations.json には safe-optional-chaining が登録されている。
"safe-optional-chaining": {
"version": "22.0.0",
"description": "Wraps optional chaining expressions in $safeNavigationMigration()."
}$safeNavigationMigration() は実行時に存在しない関数で、コンパイラがこの呼び出しを見て、中の式だけ旧挙動でコンパイルする。包んだ式と包まない式を同じテンプレートに混ぜられる。
[title]="user?.name" → domProperty('title', ctx.user?.name)
[title]="$safeNavigationMigration(user?.name)" → domProperty('title', ((ctx.user == null) ? null : ctx.user.name))
[title]="$safeNavigationMigration(user?.a?.b)" → domProperty('title', ((ctx.user == null) ? null : ((ctx.user.a == null) ? null : ctx.user.a.b)))包んだ形の比較が == null であることに注意が要る。受け手が undefined だった場合も null が返る。旧挙動は undefined を null に正規化する仕掛けだったので、包みを外すと undefined が2通りの経路から出てくる。
移行schematicが包む場所
移行はすべての ?. を包むわけではない。式の結果を受け取る側が null と undefined を区別するかどうかを上から下へ伝えて、区別する位置にある ?. だけを包む。
通常のプロパティ束縛 `[x]="…"` → 区別する
class / style / attr の束縛 → 区別しない(真偽値として読まれる)
補間 `{{ … }}` → 区別しない(文字列化で同じ出力になる)
`@if` / `*ngIf` / `@defer (when …)` → 区別しない(真偽値として読まれる)
`@for` / `*ngFor` の対象 → 区別しない
`@let` の値 → 区別する
`@switch` の式と `@case` の式 → 区別する(case がすべて非 nullish リテラルなら除く)
イベントハンドラー → 区別しない
関数の引数、パイプの入力と引数 → 常に区別する
`||` `&&` `??` `==` `!=` の両辺 → 区別しない
`===` `!==` で片方が nullish リテラル → もう一方は区別する
その他の二項演算子 → 親から継いだ値
`!`(否定)の対象 → 区別しない
三項演算子の条件 → 区別しない。分岐の2つは親から継ぐ補間が「区別しない」側なのは、null と undefined のどちらも空文字列になるためである。@if も真偽値で読まれるので同じ扱いになる。実際に包まれるのは、入力への束縛、@let、@switch、関数の引数、パイプの入力、そして === null の比較である。
else if (ast.operation === '===' || ast.operation === '!==') {
// A strict comparison with a nullish literal makes the other side null-sensitive
const leftIsNullish = isNullishLiteralAST(ast.left);
const rightIsNullish = isNullishLiteralAST(ast.right);
this.visit(ast.left, rightIsNullish);
this.visit(ast.right, leftIsNullish);
}関数の引数とパイプの入力が「常に区別する」側なので、移行後のテンプレートには、挙動が変わらない箇所にも包みが残る。{{ format(user?.name) }} の user?.name は包まれる。受け取る関数が null と undefined を同じに扱っていても、移行はそれを確かめられないためである。
包みが残っていること自体は誤りではないが、そのまま放置すると「旧挙動のまま動いている箇所」と「意図して旧挙動を保った箇所」が見分けられなくなる。移行直後のレビューで決めるのは、包みを外す基準である。
@Reviewer: 移行で入った `$safeNavigationMigration(user?.name)` が `[displayName]` の束縛に残っています。この入力の型は `string | undefined` なので、包みを外して `undefined` を渡す形に揃えられます。外せない箇所(受け手が `null` を要求する外部ライブラリの入力など)だけ残して、理由をコメントに書いてください。包んだ式の型と実行時の差
包みは実行時の挙動だけを変える。テンプレートの型検査では、この呼び出しが括弧に潰される。
if (ast.receiver.receiver instanceof ImplicitReceiver && ast.receiver.name === '$safeNavigationMigration' && ast.args.length === 1) {
const expr = this.translate(ast.args[0]);
const result = new TcbExpr(`(${expr.print()})`);
result.addParseSpanInfo(ast.sourceSpan);
return result;
}型検査ブロックは ?. をネイティブの ?. としてそのまま書き出すので、包んだ式の型には undefined が入る。実行時は null を返す。
if (this.config.strictSafeNavigationTypes) {
node = new TcbExpr(`${receiver.print()}?.${name.print()}`);
}この乖離は 22 で生まれたものではない。strictTemplates を有効にしていたプロジェクトでは、21 以前も型が undefined で実行時が null だった。22 の変更は実行時を型に合わせた側であり、残っているずれは $safeNavigationMigration() で包んだ箇所だけである。
したがって、包んだ式を null と比較すると、実行時には意図どおり動くのに型検査が通らない。逆に包みを外した式を null と比較すると、型検査が通る構成(strictTemplates を切っている場合)では無言で常に false になる。どちらの向きでも、比較の形を == null か ?? に変えるのが片付けになる。
消えた拡張診断
不要になった ?. を指摘する拡張診断が optionalChainNotNullable(NG8107)である。受け手の型に null も undefined も入っていないときに出る。
const advice = node instanceof SafePropertyRead2
? `the '?.' operator can be replaced with the '.' operator`
: `the '?.' operator can be safely removed`;
const diagnostic = ctx.makeTemplateDiagnostic(
templateMapping.span,
formatExtendedError(ErrorCode.OPTIONAL_CHAIN_NOT_NULLABLE,
`The left side of this optional chain operation does not include 'null' or 'undefined' in its type, therefore ${advice}`));22 の移行 schematic には、この診断を止めるものが入っている。strict-safe-navigation-narrow が tsconfig.json を書き換える。
json.modify(['angularCompilerOptions', 'extendedDiagnostics', 'checks', 'nullishCoalescingNotNullable'], 'suppress');
json.modify(['angularCompilerOptions', 'extendedDiagnostics', 'checks', 'optionalChainNotNullable'], 'suppress');条件は compilerOptions が空でないことだけで、該当する tsconfig.json のすべてに2行が入る。ng update を通したプロジェクトでは、不要な ?. を指摘する検査が止まった状態になる。
診断そのものは strictNullChecks が有効なときだけ作られるので、元から無効だったプロジェクトでは影響がない。有効にしていたプロジェクトでは、移行の前と後で指摘される箇所が変わる。22 への移行を確かめるレビューでは、tsconfig.json の差分にこの2行が入っているかを見て、残すか戻すかを決める。戻す場合は 'warning' か 'error' を書く。
@Reviewer: `ng update` の差分で `extendedDiagnostics.checks` に `optionalChainNotNullable: 'suppress'` が入っています。この検査は `?.` の受け手が nullable でないときに NG8107 を出すもので、22 で `?.` の結果が変わった後の片付けに使えます。移行の作業中だけ止めるなら、片付けが終わったあとで `'warning'` に戻す段取りを決めてください。レビューで探す箇所
変更の影響を受けるのは ?. の結果を使う側である。式の形で絞り込める。
// 変わる: null との厳密な比較
@if (user?.name === null) { … }
// 変わらない: == null は undefined も拾う
@if (user?.name == null) { … }
// 変わる: null を既定値として扱っていた場合
[value]="user?.name ?? ''" // ?? は変わらない(null も undefined も拾う)
[value]="user?.name || ''" // || も変わらない
// 変わる: JSON.stringify や API への送信
[payload]="{ name: user?.name }" // null だったキーが undefined になり、直列化で消える最後の形が見落としやすい。JSON.stringify は値が undefined のキーを出力から落とすため、サーバーへ送る本文からキーが消える。「値を明示的に null で消す」という API 契約を持つ更新系では、送信されるキーが変わる。テンプレートでオブジェクトを組み立てている箇所は、?? null を足すか、組み立てをコンポーネント側へ移す。
@switch も見る位置にある。@switch (user?.role) と @case (null) の組み合わせは、22 では一致しなくなる。移行 schematic はこの組み合わせを包む側に入れているので、ng update を通していれば旧挙動が保たれる。手で移行したコードでは残る。
22 の既定変更を移行 schematic の実装から読む手は、変更検知でも同じになる。Eager の書き込み条件を読んだ結果はAngular 22でOnPushが既定になった後に残るEagerをどう見抜くかにある。
レビュー観点チェックリスト
?. の結果が変わった影響を確かめる項目?.の結果を=== null/!== nullと比較している箇所がないか== nullか??で書き換えられる比較を、=== nullのまま残していないか- テンプレートで組み立てたオブジェクトを直列化して送っていないか(
undefinedのキーが消える) @switchの式に?.があり、@case (null)を持つ箇所がないか@letに束縛した?.の結果を、null前提で読んでいないか- 21 以前で publish されたライブラリの入力に、
?.の結果をそのまま渡していないか legacyOptionalChaining: trueを設定している場合、その理由が書かれているかng updateの差分にoptionalChainNotNullable: 'suppress'が入っていないか- 入った場合、戻す時期が決まっているか
- 移行で入った
$safeNavigationMigration()の包みに、外す判断が付いているか - 残した包みが、受け手が
nullを要求する箇所に限られているか strictTemplatesを有効にしているなら、包んだ式の型(undefinedを含む)と実行時の差を把握しているか
おわりに
22 の ?. はネイティブの optional chaining としてそのまま出力される。旧挙動が残るのは、legacyOptionalChaining を立てた場合、21 以前で部分コンパイルされたライブラリ、そして移行 schematic が $safeNavigationMigration() で包んだ式の3つである。包んだ式は実行時に null を返すが、型検査では括弧に潰れて undefined の型が残る。
影響が出る形は限られる。=== null の比較、@switch と @case (null) の組み合わせ、そしてテンプレートで組み立てたオブジェクトの直列化である。補間と @if と || / ?? は、どちらの挙動でも同じ結果になる。
探す手がかりを1つ減らす仕掛けが、移行 schematic に入っている。strict-safe-navigation-narrow が NG8107 を suppress にするので、?. の片付けを終えたあとに戻すかどうかを決める。テンプレート制御フローの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「テンプレート制御フロー」の群に並べてある。