skip to content

After removing the $safeNavigationMigration() wrappers an Angular 22 update added, {{ greet(user()?.firstName) }} shows 'Hello, undefined' instead of 'Hello, guest'. Why?

level: seniorimportance: should knowfreq 38%

answer

  1. what a missing user yields
  2. null before v22, undefined after
  3. the wrapper kept the old value
  4. which sinks tell null from undefined
  5. compare loosely or coalesce

basics

~20 s

Since Angular 22 the template ?. operator yields undefined like JavaScript, not null. greet() tests name === null, so undefined falls through to 'Hello, undefined'. The removed wrapper told the compiler to keep the old null result for that argument.

solid answer

~40 s

Before v22, Angular compiled `a?.b` in templates to a null check that returned `null`; since v22 it emits native optional chaining, which returns `undefined`. The `ng update` migration (`safe-optional-chaining`) wrapped expressions whose consumer can tell the two apart in `$safeNavigationMigration(...)`, a compile-time marker that keeps the legacy `null` result. Function-call arguments are such a sink, so `greet(user()?.firstName)` was wrapped. Removing the wrapper passes `undefined` into `greet`, whose `name === null` check no longer matches. The durable fix is to make the sink indifferent: `name ?? 'guest'` or `name == null` inside `greet`, or `greet(user()?.firstName ?? null)` in the template. Interpolation text, `||`, `&&`, `??`, `==` and `!` treat both values alike, which is why the migration never wrapped them; every wrapper it did add marks a consumer to check before removal.

code

ts · 20 lines
ts
import { Component, signal } from '@angular/core';

interface User { firstName: string }

@Component({
  selector: 'app-header',
  template: `<p>{{ greet(user()?.firstName) }}</p>`,
})
export class Header {
  protected readonly user = signal<User | null>(null);

  // Broken since v22 without the wrapper: undefined === null is false.
  // protected greet(name?: string | null) {
  //   return name === null ? 'Hello, guest' : 'Hello, ' + name;
  // }

  protected greet(name: string | null | undefined) {
    return 'Hello, ' + (name ?? 'guest');
  }
}

go deeper

for a junior

Remember that since Angular 22 a template ?. on a null value yields undefined, as in plain JavaScript.

for a middle

Explain which consumers can tell null from undefined and why interpolation text, ?? and || do not care.

for a senior

Diagnose regressions after migration clean-ups, fix consumers to accept both nullish values, and remove wrappers with targeted tests.

for a principal

Plan the retirement of migration markers across a large codebase: ordering, test coverage for null paths, and when a global compiler flag is acceptable.

## The scenario A header shows a greeting: - template: `{{ greet(user()?.firstName) }}` - class: `greet(name?: string | null) { return name === null ? 'Hello, guest' : 'Hello, ' + name; }` It worked for years. The team upgraded to Angular 22 with `ng update`, then ran a cleanup that deleted every `$safeNavigationMigration(...)` call the update had inserted. Now a signed-out visitor sees **Hello, undefined**. ## What changed in v22 The template **safe-navigation operator** `?.` short-circuits when its left side is `null` or `undefined`. The question is what the whole expression evaluates to then: | Version | `user()?.firstName` when `user()` is `null` | |---|---| | before v22 | `null` - Angular compiled `?.` to its own null-check ternary | | v22 and later | `undefined` - Angular emits native JavaScript optional chaining | The change aligns templates with TypeScript code in the class. The commit in the v22 changelog is titled "Angular expressions with optional chaining returns `undefined`". ## What the migration did `ng update` for v22 runs the `safe-optional-chaining` migration. It walks every template expression with one question: does the consumer of this value distinguish `null` from `undefined`? If yes, it wraps the chain in `$safeNavigationMigration(...)`. That "function" does not exist at runtime - it is a **marker** the compiler recognises and compiles back to the legacy `null`-returning form. Its sink rules: - **Null-sensitive (wrapped):** function-call arguments, pipe inputs, regular property bindings to element or component properties, operands of `===`/`!==` compared with a `null` or `undefined` literal, and arithmetic or comparison operators whose result feeds a null-sensitive sink. - **Not null-sensitive (left alone):** interpolation text (both render as `''`), `||`, `&&`, `??`, `==`, `!=`, `!`, ternary conditions, `@if` and `@for` expressions, and `[class]`, `[style]` and `[attr.*]` bindings. `greet(user()?.firstName)` passes the chain as a **function argument**, so it was wrapped - and it was the wrapper that kept `greet` receiving `null`. ## Why the cleanup broke it With the wrapper gone, the argument is `undefined`. Inside `greet`, `undefined === null` is `false`, so the function takes the other branch and concatenates `'Hello, ' + undefined`. Nothing threw; the value just took a different path. The template type checker stayed silent too, because the optional parameter already admits `undefined`: the bug is in the runtime check, not in the types. ## How to fix it properly 1. **Make the consumer tolerate both nullish values.** In the class: `return 'Hello, ' + (name ?? 'guest')`, or test `name == null`. Type the parameter `string | null | undefined` (or `name?: string`). 2. **Normalise in the template** when the consumer cannot change: `greet(user()?.firstName ?? null)`. 3. **Remove wrappers one by one**, each with a test of the null path, rather than by global search-and-replace. The Angular docs describe `$safeNavigationMigration` as a temporary aid that may be removed in a future version, so it should not stay forever. There is also an `angularCompilerOptions` flag, `legacyOptionalChaining`, that makes every template `?.` return `null` again; it is a blanket escape hatch rather than a fix. ## Where else the difference shows - **Template literals**: `` {{ `Hello, ${user()?.firstName}` }} `` prints `Hello, undefined` now (`Hello, null` before) - interpolation hides nullish values only when they are the whole interpolated value. - **Inputs that treat `null` as a meaningful state**, such as "explicitly none", receive `undefined` instead. - **Strict equality checks** in `@if` conditions: `@if (user()?.plan === null)` stops matching. ## Diagnosing it quickly 1. Search the diff for removed `$safeNavigationMigration(` calls. 2. For each, identify the sink: a call, a pipe, an input, a strict comparison. 3. Add a test with the left side of `?.` set to `null` and assert the rendered text.

  • Why did the migration leave {{ user()?.firstName }} alone but wrap greet(user()?.firstName)?
    Interpolation renders both `null` and `undefined` as an empty string, so the result cannot differ. A function argument can be compared, stored or concatenated by the callee, which may distinguish the two, so the migration treats call arguments as null-sensitive and wraps them.
  • Is $safeNavigationMigration something you can call from TypeScript?
    No. It is a marker the Angular template compiler recognises and removes, compiling the wrapped chain with the legacy `null` result. It does not exist at runtime or in TypeScript, and the docs describe it as a temporary aid to remove once code no longer depends on `null` versus `undefined`.

saying these in an interview costs you the question

  • In Angular 22 templates, ?. still returns null like before
  • $safeNavigationMigration is a runtime helper exported by @angular/core
  • Every wrapper must stay forever or behaviour changes
  • Interpolated {{ a?.b }} now prints the word undefined
  • The fix is to replace ?. with ! everywhere