Angular 22で?.の結果がundefinedになった影響をレビューで追う

Angular 22 のテンプレートで ?. を書くと、生成されるコードが JavaScript の optional chaining そのものになる。JIT でコンパイルした更新関数を取り出すと次のようになる。

@angular/core 22.2.1 / <div [title]="user?.name"></div>
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 の分岐である。

@angular/compiler 22.2.1 の 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 のオプションとして公開されている。

@angular/compiler-cli 22.2.1 の public_options.d.ts(抜粋)
/**
 * 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 はライブラリの宣言に書かれた版を見て、この値を決める。

@angular/compiler-cli 22.2.1 の Linker(抜粋)
legacyOptionalChaining: major < 22 && version !== PLACEHOLDER_VERSION,

21 で publish された npm パッケージのコンポーネントは、22 のアプリに組み込んでも ?. が null を返す。アプリ側のテンプレートは undefined を返す。同じ記法の挙動がパッケージ単位で分かれるため、ライブラリの入力に渡す値の扱いは、呼ぶ側の挙動で考える。

3つめが移行 schematic である。@angular/core の migrations.json には safe-optional-chaining が登録されている。

@angular/core 22.2.1 の migrations.json(抜粋)
"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 を区別するかどうかを上から下へ伝えて、区別する位置にある ?. だけを包む。

ExpressionMigrator が伝える null-sensitive の値(22.2.1 の実装)
通常のプロパティ束縛 `[x]="…"`        → 区別する
class / style / attr の束縛           → 区別しない(真偽値として読まれる)
補間 `{{ … }}`                        → 区別しない(文字列化で同じ出力になる)
`@if` / `*ngIf` / `@defer (when …)`   → 区別しない(真偽値として読まれる)
`@for` / `*ngFor` の対象              → 区別しない
`@let` の値                           → 区別する
`@switch` の式と `@case` の式          → 区別する(case がすべて非 nullish リテラルなら除く)
イベントハンドラー                     → 区別しない
関数の引数、パイプの入力と引数         → 常に区別する
`||` `&&` `??` `==` `!=` の両辺        → 区別しない
`===` `!==` で片方が nullish リテラル  → もう一方は区別する
その他の二項演算子                     → 親から継いだ値
`!`(否定)の対象                      → 区別しない
三項演算子の条件                       → 区別しない。分岐の2つは親から継ぐ

補間が「区別しない」側なのは、null と undefined のどちらも空文字列になるためである。@if も真偽値で読まれるので同じ扱いになる。実際に包まれるのは、入力への束縛、@let、@switch、関数の引数、パイプの入力、そして === null の比較である。

22.2.1 の visitBinary(抜粋)
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 を同じに扱っていても、移行はそれを確かめられないためである。

包みが残っていること自体は誤りではないが、そのまま放置すると「旧挙動のまま動いている箇所」と「意図して旧挙動を保った箇所」が見分けられなくなる。移行直後のレビューで決めるのは、包みを外す基準である。

Comment
@Reviewer: 移行で入った `$safeNavigationMigration(user?.name)` が `[displayName]` の束縛に残っています。この入力の型は `string | undefined` なので、包みを外して `undefined` を渡す形に揃えられます。外せない箇所(受け手が `null` を要求する外部ライブラリの入力など)だけ残して、理由をコメントに書いてください。

包んだ式の型と実行時の差

包みは実行時の挙動だけを変える。テンプレートの型検査では、この呼び出しが括弧に潰される。

@angular/compiler 22.2.1 の型検査ブロックの生成(抜粋)
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 を返す。

22.2.1 の visitSafePropertyRead(型検査ブロック側・抜粋)
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 も入っていないときに出る。

@angular/compiler-cli 22.2.1 の OptionalChainNotNullableCheck(抜粋)
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 を書き換える。

@angular/core 22.2.1 の strict-safe-navigation-narrow.cjs(抜粋)
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' を書く。

Comment
@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観点の「テンプレート制御フロー」の群に並べてある。