skip to content

In an Angular template, what does `@if (unread$ | async; as count)` give you, and why can it hide a legitimate zero?

level: middleimportance: should knowfreq 52%

answer

  1. one pipe, many reads
  2. alias scoped to the block
  3. truthiness decides rendering
  4. null, 0, empty string, false
  5. @let or a view-model object

basics

~20 s

Angular's @if (unread$ | async; as count) subscribes once and exposes the value as count inside the block, but the block renders only for truthy values, so null before loading and a real 0 both hide it.

solid answer

~40 s

The `as` clause of `@if` stores the result of the condition expression in a block-scoped alias, so the async pipe runs once and every binding inside reads `count` instead of piping `unread$` again, which would add subscriptions. The price is that the condition is also the render test: the block appears only when the value is **truthy**. `null` before the first emission hides it (usually wanted), but so do `0`, `''` and `false`, which are real data. For values that may be falsy, use `@let count = unread$ | async;` (stable since v19) and branch explicitly on `count !== null`, or pipe a view-model object such as `{ count }`, which is always truthy. The legacy `*ngIf="unread$ | async as count"` has the same falsy behaviour and is deprecated since v20.

code

ts · 19 lines
ts
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-unread-badge',
  imports: [AsyncPipe],
  template: `
    @let count = unread$ | async;
    @if (count !== null) {
      <span class="badge">{{ count }}</span>
    } @else {
      <span class="badge">...</span>
    }
  `,
})
export class UnreadBadge {
  readonly unread$ = inject(HttpClient).get<number>('/api/notifications/unread-count');
}

go deeper

for a junior

Know the pattern @if (x$ | async; as x) for using one async value in several bindings, and remember the block hides while x is null.

for a middle

Explain that visibility follows truthiness, list the falsy values that hide the block, and show the @let or view-model fix with an explicit null check.

for a senior

Spot badges, toggles and counters that vanish at 0 or false in review, and steer migrations from *ngIf aliases to @if or @let without changing behaviour.

for a principal

Decide a template convention for loading versus empty versus zero states so every team renders them the same way.

## The pattern A template often needs one asynchronous value in several places. Writing `unread$ | async` in each binding creates one `AsyncPipe` instance, and one subscription, per binding. The `as` clause of `@if` avoids that: ```html @if (unread$ | async; as count) { <span class="badge">{{ count }}</span> <span class="sr-only">{{ count }} unread notifications</span> } ``` The condition expression `unread$ | async` is evaluated once per check by a single pipe instance. Its result is saved into **`count`**, a template variable visible only inside that block. Both bindings read `count`, so there is **one subscription** however many times the value is used. ## The catch: the condition is also a truthiness test `@if` renders its block when the condition is **truthy**. With an alias, the value you want to show *is* the condition, so every falsy value hides the block: | Value from `unread$ \| async` | Block renders? | Usually intended? | |---|---|---| | `null` (nothing emitted yet) | no | yes, it is the loading phase | | `0` | no | **no**, zero unread is real data | | `''` (empty string) | no | often no | | `false` | no | often no | | `[]` (empty array) | yes, arrays are truthy | depends | | `-1`, `'0'` | yes, both truthy | yes | So a badge written this way disappears exactly when the count reaches zero, which may be the design, and silently hides a `0` that should have been shown, which is the bug interviewers ask about. The same applies to a boolean stream: `@if (isAdmin$ | async; as admin)` can never render its block with `admin` equal to `false`. The alias is also only in scope inside the `@if` block. An `@else` branch cannot read it, which suits a loading placeholder but not a branch that wants the value. ## Alternatives when the value can be falsy 1. **`@let`** (stable since v19) declares a template variable from any expression, with one pipe instance, readable after its declaration in the same view and the blocks nested in it: ```html @let count = unread$ | async; @if (count !== null) { <span class="badge">{{ count }}</span> } @else { <span class="badge">...</span> } ``` Rendering and loading are now decided by an explicit comparison, not by truthiness. 2. **A view-model object.** Map the stream to `{ count }` in the component (`unread$.pipe(map(count => ({ count })))`). An object is always truthy, so `@if (vm$ | async; as vm)` renders as soon as the first value exists and `vm.count` can be `0`. 3. **A seeded stream** (`startWith(0)`) removes the `null` phase entirely, though a `0` still hides an `@if` alias, so combine it with `@let` or an object. ## Legacy syntax Older templates use `*ngIf="unread$ | async as count"`. The structural directive has the same truthiness rule and the same single-subscription benefit. `*ngIf` is deprecated since v20; the built-in `@if` is the current recommendation, and the control-flow migration converts one to the other. ## What interviewers listen for - The alias exists to get **one subscription** and a narrowed, non-null value inside the block. - The block's visibility follows **truthiness**, so `0`, `''` and `false` hide it. - The alias is **scoped to the block**, not visible in `@else`. - **`@let`** or a **view-model object** is the fix when falsy values are real data.

  • Inside `@if (user$ | async; as user)`, is `user` typed as possibly null?
    No. The block renders only for a truthy condition, so the alias is narrowed to the non-null value type and bindings like `user.name` need no `?.`. Outside the block the alias does not exist at all.
  • If a stream emits an empty array, does `@if (items$ | async; as items)` hide the block?
    No. An empty array is truthy in JavaScript, so the block renders with `items` equal to `[]`. Only null before the first emission hides it; showing an empty-list message is then a check on `items.length` or an `@empty` branch of an inner `@for`.

saying these in an interview costs you the question

  • The as alias only hides the block while the value is null
  • Each binding inside the block creates its own subscription to the stream
  • The alias is also available in the @else branch
  • An empty array from the stream hides an @if block
  • @let with the async pipe subscribes once per binding that reads it