Angular NotesRohit’s interview study guide
Chapter 05

Pipes

Transforming values in templates — built-in pipes, custom pipes, purity, and the async pipe.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What pipes are

A pipe is a small function you call from a template to turn a value into the thing you want to display: a Date into Mar 5, 2026, 1234.5 into $1,234.50, an observable into its latest value. You write the value, a |, then the pipe's name: {{ placed | date }}. The component keeps the raw data; the pipe only decides how it looks.

The mental model is a Unix pipe at display time. Data flows left to right through one or more transforms, and whatever comes out the right-hand end is what Angular writes into the DOM. Nothing flows back: the pipe never changes placed on the component, it just returns a new value for this binding.

Under the hood a pipe is a class with a transform() method and a @Pipe({ name }) decorator. When the template compiler sees placed | date, it creates one instance of DatePipe for that spot in the view and, during change detection, calls transform(placed) on it. Two things make pipes more than "a function call with funny syntax", and both come up in interviews:

  • Purity — by default Angular remembers the last input and skips transform() when the input hasn't changed (see Pure and impure pipes). That's what makes a pipe cheaper than calling a method from the template (Pipes vs method calls).
  • State — because each usage gets its own instance, a pipe can hold state between checks. The async pipe uses that to keep a subscription open (The async pipe).
order-summary.ts
import { Component } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';

@Component({
  selector: 'app-order-summary',
  imports: [DatePipe, CurrencyPipe],
  template: `<p>Placed {{ placed | date }} for {{ price | currency }}</p>`,
})
export class OrderSummary {
  placed = new Date(2026, 2, 5, 14, 7);
  price = 1234.5;
}
What’s happening
  1. imports: [DatePipe, CurrencyPipe] makes the two pipes available to this template. Built-in pipes live in @angular/common and are standalone, so you import exactly the ones you use (the old NgModule world got them all at once from CommonModule).
  2. The template compiler sees placed | date and price | currency and creates one DatePipe instance and one CurrencyPipe instance for this view.
  3. On the first change detection Angular calls datePipe.transform(placed) and currencyPipe.transform(price). The component's placed is still a Date and price is still the number 1234.5 — only the text written into the <p> is formatted.
  4. The rendered text is Placed Mar 5, 2026 for $1,234.50 in the default en-US locale (verified in a TestBed test). date with no argument uses the mediumDate format; currency with no argument uses USD.
  5. If you forget the import, the build fails with NG8004: No pipe found with name 'date'. — the compiler checks pipe names just like component selectors.
AdvancedWhen to use a pipe

Use a pipe for display formatting and light, reusable transforms of a value. Business logic, filtering a big list or anything that decides what data exists belongs in the component (ideally in a computed()) or a service; a pipe decides only how a value looks.

#Built-in pipes

Angular ships a set of pipes in @angular/common. All of them are locale-aware where it matters, using LOCALE_ID (which defaults to en-US). The outputs below are the real rendered text from a TestBed test in Angular 22.2, en-US locale.

builtins.ts
@Component({
  selector: 'app-builtins',
  imports: [
    DatePipe, CurrencyPipe, DecimalPipe, PercentPipe, JsonPipe,
    KeyValuePipe, UpperCasePipe, LowerCasePipe, TitleCasePipe, SlicePipe,
  ],
  templateUrl: './builtins.html',
})
export class Builtins {
  placed = new Date(2026, 2, 5, 14, 7); // 5 March 2026, 14:07 local time
  price = 1234.5;
  ratio = 3141.59265;
  name = 'aNGULAR signals rock';
  tags = ['ng', 'rxjs', 'signals'];
  user = { id: 7, roles: ['admin'] };
  scores = { zoe: 3, adam: 9, mia: 5 };
}
AdvancedBuilt-in pipes with their options
builtins.html
<!-- date -->
{{ placed | date }}                       <!-- Mar 5, 2026 -->
{{ placed | date: 'short' }}              <!-- 3/5/26, 2:07 PM -->
{{ placed | date: 'fullDate' }}           <!-- Thursday, March 5, 2026 -->
{{ placed | date: 'yyyy-MM-dd HH:mm' }}   <!-- 2026-03-05 14:07 -->
{{ placed | date: 'h:mm a' }}             <!-- 2:07 PM -->

<!-- currency -->
{{ price | currency }}                         <!-- $1,234.50 -->
{{ price | currency: 'EUR' }}                  <!-- €1,234.50 -->
{{ price | currency: 'CAD' }}                  <!-- CA$1,234.50 -->
{{ price | currency: 'CAD' : 'symbol-narrow' }} <!-- $1,234.50 -->
{{ price | currency: 'INR' : 'code' : '1.0-0' }} <!-- INR1,235 -->

<!-- number and percent -->
{{ ratio | number }}           <!-- 3,141.593 -->
{{ ratio | number: '1.0-0' }}  <!-- 3,142 -->
{{ 7 | number: '3.2-2' }}      <!-- 007.00 -->
{{ 0.256 | percent }}          <!-- 26% -->
{{ 0.256 | percent: '1.1-1' }} <!-- 25.6% -->

<!-- text -->
{{ name | uppercase }}  <!-- ANGULAR SIGNALS ROCK -->
{{ name | lowercase }}  <!-- angular signals rock -->
{{ name | titlecase }}  <!-- Angular Signals Rock -->

<!-- slice, json, keyvalue -->
{{ tags | slice: 0 : 2 }}  <!-- ng,rxjs -->
{{ name | slice: -5 }}     <!-- " rock" (5 characters, including the space) -->
<pre>{{ user | json }}</pre> <!-- pretty-printed JSON, 2-space indent -->
@for (entry of scores | keyvalue; track entry.key) {
  <li>{{ entry.key }}={{ entry.value }}</li>   <!-- adam=9, mia=5, zoe=3 -->
}
What’s happening
  1. date formats a Date, a timestamp number or an ISO string. With no argument it uses mediumDate. Named formats (short, fullDate, mediumTime, …) come from the locale's data; a custom pattern like 'yyyy-MM-dd HH:mm' uses Unicode date symbols (MM month, mm minutes, HH 24-hour, h 12-hour, a AM/PM). The time zone is the browser's unless you pass one (date: 'short' : 'UTC') or provide DATE_PIPE_DEFAULT_OPTIONS.
  2. currency takes currencyCode (default USD), display ('symbol' default, 'symbol-narrow', 'code', or any literal string), digitsInfo and locale. Note the difference between CA$1,234.50 (wide symbol, unambiguous) and $1,234.50 (narrow symbol). '1.0-0' rounds to whole units, so 1234.5 becomes 1,235, and 'code' prints INR1,235 with no space.
  3. digitsInfo is '{minInt}.{minFrac}-{maxFrac}'. The default for number is '1.0-3', which is why 3141.59265 shows three decimals: 3,141.593. '3.2-2' forces at least three integer digits and exactly two decimals, so 7 becomes 007.00. percent multiplies by 100 first and defaults to '1.0-0', so 0.256 is 26%.
  4. uppercase / lowercase / titlecase only accept strings — {{ total | uppercase }} with a number is a compile error under strict templates (TS2769: No overload matches this call … Argument of type 'number' is not assignable to parameter of type 'string'). titlecase capitalises the first letter of each word and lowercases the rest.
  5. slice works like Array.prototype.slice / String.prototype.slice, including negative indexes. json is JSON.stringify(value, null, 2) — handy for debugging, wrap it in <pre> to keep the indentation.
  6. keyvalue turns an object or Map into an array of { key, value } so @for can loop over it, and sorts by key by default (adam, mia, zoe, not insertion order). Pass your own compare function as an argument to change the order, or null to keep insertion order.
  7. json, keyvalue, slice and async are impure pipes in Angular 22 (they're declared with pure: false in the @angular/common source); the rest are pure. That matters for performance and for mutation — see Pure and impure pipes.
AdvancedTesting pitfall: U+202F in time formats
Advancedi18nPlural, i18nSelect and locales

Two more built-ins you'll meet less often are i18nPlural and i18nSelect, which pick a string by number or by key. For dates, numbers and currencies in another locale, register the locale data (registerLocaleData) and provide LOCALE_ID, or pass the locale as the last argument.

#Pipe arguments and chaining

Arguments go after the pipe name, each introduced by a colon: value | pipe: arg1 : arg2. They map positionally onto the extra parameters of transform(value, arg1, arg2). Arguments can be any template expression — literals, component fields, signals called with ().

Chaining feeds the output of one pipe into the next: value | a | b is b.transform(a.transform(value)). Order matters, because each pipe only sees what the previous one returned.

chaining.html
<!-- title = 'Understanding Angular pipes in depth', placed = 5 Mar 2026, isAdmin = true -->
<p>{{ title | truncate }}</p>                    <!-- Understanding Angula… -->
<p>{{ title | truncate: 10 }}</p>                <!-- Understand… -->
<p>{{ title | truncate: 10 : '...' }}</p>        <!-- Understand... -->
<p>{{ title | truncate: 10 | uppercase }}</p>    <!-- UNDERSTAND… -->
<p>{{ placed | date: 'MMM d' | uppercase }}</p>  <!-- MAR 5 -->

<p>{{ isAdmin ? 'yes' : 'no' | uppercase }}</p>   <!-- yes  (!) -->
<p>{{ (isAdmin ? 'yes' : 'no') | uppercase }}</p> <!-- YES -->
<p>{{ (placed | date: 'yyyy') + '!' }}</p>       <!-- 2026! -->
What’s happening
  1. truncate is the custom pipe from the next topic: transform(value, limit = 20, suffix = '…'). With no arguments, both defaults apply: the first 20 characters, trimmed, plus ….
  2. truncate: 10 passes 10 as limit; truncate: 10 : '...' passes both. The colons are positional — there's no way to skip limit and pass only suffix.
  3. title | truncate: 10 | uppercase runs left to right: truncate returns 'Understand…', then uppercase receives that string and returns 'UNDERSTAND…'. The arguments : 10 belong to truncate, the pipe immediately to their left.
  4. placed | date: 'MMM d' | uppercase — date turns the Date into the string 'Mar 5', and only then can uppercase work on it. Reversing the order (placed | uppercase | date) would fail to compile because uppercase needs a string.
  5. The surprise: isAdmin ? 'yes' : 'no' | uppercase renders yes. The pipe operator binds tighter than the ternary, so it parses as isAdmin ? 'yes' : ('no' | uppercase) — only the else branch is piped. Wrap the ternary in parentheses to pipe its result: YES.
  6. The same rule means you need parentheses to use a pipe's result in a larger expression: (placed | date: 'yyyy') + '!' gives 2026!.
AdvancedHow pure pipes compare arguments

#Custom pipes

When you keep writing the same formatting logic, wrap it in a pipe. A custom pipe is a class with the @Pipe decorator and a transform method; implementing the PipeTransform interface is optional but documents the contract and gets the signature checked.

truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
  transform(value: string | null | undefined, limit = 20, suffix = '…'): string {
    if (!value) return '';
    if (value.length <= limit) return value;
    return value.slice(0, limit).trimEnd() + suffix;
  }
}
AdvancedUsing truncate in a pipe chain
chaining.ts
@Component({
  selector: 'app-chaining',
  imports: [DatePipe, UpperCasePipe, TruncatePipe],
  templateUrl: './chaining.html',
})
export class Chaining {
  title = 'Understanding Angular pipes in depth';
  placed = new Date(2026, 2, 5);
  isAdmin = true;
}
What’s happening
  1. @Pipe({ name: 'truncate' }) registers the name used in templates. Names are lowerCamelCase and can't contain hyphens. In Angular 22 the pipe is standalone by default, so there's no standalone: true and no NgModule declarations — any component that wants it lists TruncatePipe in imports.
  2. The first parameter of transform is always the value to the left of |. Every parameter after it is a pipe argument, in order. Giving them defaults (limit = 20, suffix = '…') is what lets the template write just title | truncate.
  3. The value type is string | null | undefined on purpose. Template values are often not ready yet — an async pipe returns null first, an optional field may be undefined — and accepting them means (user$ | async)?.bio | truncate type-checks. Returning '' for empty input keeps the output type a plain string.
  4. slice(0, limit).trimEnd() cuts at 20 characters ('Understanding Angula') and removes any trailing space so you don't get 'word …'; then it appends the suffix.
  5. Strict templates type-check pipes like functions: {{ 42 | truncate }} is a compile error, and so is passing a string where limit: number is expected. If the component forgets to import the pipe, you get NG8004: No pipe found with name 'truncate'.
  6. The pipe is pure (the default), so for the same title and arguments Angular calls transform once and reuses the result on later checks — the next topic is about exactly that.
AdvancedInjecting services into a pipe

A pipe can also use dependency injection — inject() works in a field initialiser because Angular creates pipes in an injection context. That's how the built-in DatePipe reads LOCALE_ID and how you'd write a pipe that formats using a user-preferences service. Keep in mind that a pure pipe won't re-run when that injected state changes; only its input and arguments are compared.

AdvancedOlder syntax: declaring pipes in NgModules

#Pure and impure pipes

My notes flagged this as very important. A pure pipe (the default) is treated like a pure function: same input and arguments → same output. Angular stores the last input and arguments for each pipe usage and, on every change detection, compares the new ones with ===. If nothing changed, it reuses the last result and doesn't call transform() at all.

That reference check is the whole story. A primitive ('abc', 42) changes when its value changes. An object or array changes only when you replace it with a new reference; pushing into an array or setting a property on an object leaves the reference the same, so a pure pipe never sees the change.

An impure pipe (pure: false) skips the cache: transform() runs on every change detection of the view, whether or not anything changed. That lets it notice mutation, at the cost of running far more often. The mental model: a pure pipe is a memoised function keyed on references; an impure pipe is a method call in a pipe's clothes.

purity.ts
export const calls = { pure: 0, impure: 0 };

@Pipe({ name: 'pending' })
export class PendingPipe implements PipeTransform {
  transform(todos: Todo[]): Todo[] {
    calls.pure++;
    return todos.filter((t) => !t.done);
  }
}

@Pipe({ name: 'pendingImpure', pure: false })
export class PendingImpurePipe implements PipeTransform {
  transform(todos: Todo[]): Todo[] {
    calls.impure++;
    return todos.filter((t) => !t.done);
  }
}

@Component({
  selector: 'app-purity',
  imports: [PendingPipe, PendingImpurePipe],
  template: `
    <p id="pure">{{ (todos | pending).length }} pending (pure)</p>
    <p id="impure">{{ (todos | pendingImpure).length }} pending (impure)</p>
    <p>{{ clicks() }}</p>
    <button id="mutate" (click)="addByPush()">push</button>
    <button id="replace" (click)="addByCopy()">copy</button>
    <button id="noop" (click)="clicks.set(clicks() + 1)">other</button>
  `,
})
export class Purity {
  todos: Todo[] = [
    { title: 'Learn pipes', done: false },
    { title: 'Learn signals', done: true },
  ];
  clicks = signal(0);

  addByPush() {
    this.todos.push({ title: 'Write tests', done: false }); // same array reference
  }

  addByCopy() {
    this.todos = [...this.todos, { title: 'Ship it', done: false }]; // new reference
  }
}
AdvancedSpec: pure vs impure pipe runs
purity.spec.ts
calls.pure = calls.impure = 0;
const f = TestBed.createComponent(Purity);
await f.whenStable();
const el = f.nativeElement as HTMLElement;
expect(calls).toEqual({ pure: 1, impure: 1 });

click(el, '#noop');
await f.whenStable();
expect(calls).toEqual({ pure: 1, impure: 2 });

click(el, '#mutate');
await f.whenStable();
expect(text(el, '#pure')).toBe('1 pending (pure)'); // stale
expect(text(el, '#impure')).toBe('2 pending (impure)');
expect(calls).toEqual({ pure: 1, impure: 3 });

click(el, '#replace');
await f.whenStable();
expect(text(el, '#pure')).toBe('3 pending (pure)');
expect(calls).toEqual({ pure: 2, impure: 4 });
What’s happening
  1. First render. Both pipes run once and both show 1 pending (only "Learn pipes" is not done). The pure pipe records the input it saw: the todos array reference. Counts: { pure: 1, impure: 1 }.
  2. Unrelated click. The other button updates the clicks signal; the click also marks the view for checking, so the template runs again. The pure pipe compares todos === lastTodos → true, so Angular reuses the cached 1 and doesn't call transform. The impure pipe runs anyway. Counts: { pure: 1, impure: 2 }.
  3. push. addByPush() adds an undone todo into the same array. The click triggers change detection, the pure pipe compares references again — still the same array — and returns the cached result. The page now says 1 pending (pure) next to 2 pending (impure): the pure pipe is showing stale data. Counts: { pure: 1, impure: 3 }.
  4. copy. addByCopy() builds a new array with the spread operator. Now todos !== lastTodos, so the pure pipe calls transform and sees all three pending todos (the pushed one is in there too, since the copy included it). Counts: { pure: 2, impure: 4 }.
  5. So the impure pipe was "correct" every time, but it ran on all four checks, including the one where nothing changed. With a big list and a filter or sort inside, that's real work on every click, keystroke or timer anywhere that causes this view to be checked.
  6. The fix is almost never pure: false. It's immutable updates — replace the array instead of mutating it ([...list, item], list.filter(…), list.map(…)). That's the same rule OnPush and signals rely on (see The mutation problem).
AdvancedWhen an impure pipe is right

When an impure pipe is genuinely right:

  • The output depends on something other than the input reference — the async pipe (the observable's latest value), a "time ago" pipe that changes as the clock moves, a translation pipe whose language can change at runtime.
  • You need to see in-place mutation of something you don't control. json and keyvalue are impure for that reason.

If you do write one, keep transform cheap, and cache inside it if you can — KeyValuePipe uses a differ so that it only rebuilds its array when the object actually changed.

AdvancedPitfall: mutating arrays inside signals
Does the pure pipe update?
TypeScript
user = { name: 'ada' };
rename() { this.user.name = 'grace'; }
// template: {{ user | displayName }}  — displayName is pure; rename() runs from a click
Show answer

No. It keeps showing Ada (or whatever it produced for 'ada').

What’s happening
  1. On the first check the pipe receives the user object and Angular stores that reference.
  2. rename() changes a property on the same object. The click marks the view for checking, so the template is evaluated again.
  3. Angular compares user === lastUser → true. For a pure pipe that means "nothing changed", so transform isn't called and the old string is reused.
  4. Fix it by replacing the object (this.user = { ...this.user, name: 'grace' }), or by piping the primitive that actually changes: {{ user.name | displayName }} re-runs because the string 'grace' !== 'ada'.

#Pipes vs method calls

My notes mark this "very important !!!!". In a template, {{ total() }} where total is a method looks harmless, but Angular can't know whether a method's result changed without calling it. So a method in a template runs every time the view is checked. A pure pipe with the same logic runs only when its input reference changes, and a computed() signal runs only when a signal it read changes.

How often "every time the view is checked" is depends on the change detection model. In the old zone.js world with the Default strategy, every click, timer or HTTP response anywhere in the app checked every component, so a template method could run hundreds of times. In Angular 22, new components are OnPush by default (the ChangeDetectionStrategy docs say "OnPush is enabled by default"; the old Default is now called Eager) and apps are zoneless, so a view is checked less often — but every check still re-runs every method call in it. Here's the count:

cart.ts
export const counts = { method: 0, pipe: 0, computed: 0 };

@Pipe({ name: 'cartTotal' })
export class CartTotalPipe implements PipeTransform {
  transform(lines: Line[]): number {
    counts.pipe++;
    return lines.reduce((sum, l) => sum + l.price * l.qty, 0);
  }
}

@Component({
  selector: 'app-cart',
  imports: [CartTotalPipe],
  template: `
    <p>{{ totalMethod() }}</p>
    <p>{{ lines | cartTotal }}</p>
    <p>{{ total() }}</p>
    <p id="likes">{{ likes() }}</p>
    <button (click)="likes.set(likes() + 1)">like</button>
  `,
})
export class Cart {
  lines: Line[] = [
    { price: 10, qty: 2 },
    { price: 5, qty: 1 },
  ];
  likes = signal(0);

  linesSig = signal(this.lines);
  total = computed(() => {
    counts.computed++;
    return this.linesSig().reduce((sum, l) => sum + l.price * l.qty, 0);
  });

  totalMethod(): number {
    counts.method++;
    return this.lines.reduce((sum, l) => sum + l.price * l.qty, 0);
  }
}
AdvancedSpec: method vs pipe vs computed runs
cart.spec.ts
counts.method = counts.pipe = counts.computed = 0;
const f = TestBed.createComponent(Cart);
await f.whenStable();
expect(counts).toEqual({ method: 1, pipe: 1, computed: 1 });

for (let i = 0; i < 3; i++) {
  clickText(f.nativeElement, 'like');
  await f.whenStable();
}
expect(counts).toEqual({ method: 4, pipe: 1, computed: 1 });
What’s happening
  1. First render. All three bindings need a value, so all three run once and all show 25 (10 × 2 + 5 × 1). Counts: { method: 1, pipe: 1, computed: 1 }.
  2. Click "like" (×3). Each click updates the likes signal, which has nothing to do with the cart. The click and the signal both mark this view for checking, so Angular re-evaluates every binding in the template, three times in total.
  3. {{ totalMethod() }} is just a function call in the compiled template, so it runs on each of the three checks: 1 → 4. The answer is 25 every time; the work is wasted.
  4. {{ lines | cartTotal }}: on each check Angular compares lines with the reference it stored — same array — and reuses 25 without calling transform. Still 1.
  5. {{ total() }}: reading a computed returns its cached value unless one of the signals it read (linesSig) changed. likes isn't one of them, so it doesn't recompute. Still 1.
  6. With a cheap reduce over two lines nobody would notice. With a sort or filter over thousands of rows, or a method that builds a new array (which then makes a child's inputs "change" every time), it's a real cost — and in an Eager / zone.js app it multiplies across every event in the app. That's the rule from Avoid heavy work in templates.
AdvancedMethod, pipe or computed comparison

Which to choose:

Re-runs whenGood for
Method call {{ fn() }}Every check of the viewTrivial getters only
Pure pipe {{ x | p }}Input or an argument changes (===)Reusable formatting across many components
computed()A signal it read changesComponent-specific derived state

In modern Angular, computed() is usually the better tool for derived state that belongs to one component, and pipes stay the tool for reusable formatting (date, currency, your own truncate). Both are memoised; computed also works in TypeScript code, not just templates.

#The async pipe

My notes call this one a head twister, so let's take it slowly. The async pipe takes an Observable (or anything with subscribe) or a Promise, subscribes for you, returns the latest value it has received, and unsubscribes when the view is destroyed. Before the first value arrives it returns null.

Think of it as a mailbox the view owns. On the first check, the pipe opens the mailbox (subscribes). Every time the observable emits, the value is dropped in the mailbox and the pipe asks Angular to check this view again (markForCheck). Each check simply reads whatever is in the mailbox. When the component is destroyed, the mailbox is closed (unsubscribe), so there's no leak.

This is the Angular 22 source of the pipe's transform, trimmed (@angular/common):

async pipe (Angular 22.2 source, trimmed)
transform(obj) {
  if (!this._obj) {
    if (obj) {
      this.markForCheckOnValueUpdate = false;
      this._subscribe(obj);          // first call: subscribe
      this.markForCheckOnValueUpdate = true;
    }
    return this._latestValue;        // null until something arrives
  }
  if (obj !== this._obj) {           // a different observable?
    this._dispose();                 // unsubscribe from the old one, reset to null
    return this.transform(obj);      // and subscribe to the new one
  }
  return this._latestValue;
}

_updateLatestValue(async, value) {   // called on every emission
  if (async === this._obj) {
    this._latestValue = value;
    if (this.markForCheckOnValueUpdate) this._ref?.markForCheck();
  }
}

ngOnDestroy() { this._dispose(); }   // unsubscribe with the view
What’s happening
  1. The pipe is declared pure: false, so transform runs on every check of the view. It has to: the observable reference stays the same while its value changes, and a pure pipe would never notice.
  2. First call: there's no _obj yet, so it subscribes and returns _latestValue. For an asynchronous source (HTTP, a Subject, a timer) that's still null. For a synchronous one like of(5) the value arrives during subscribe, so the very first call already returns 5 — and markForCheckOnValueUpdate = false stops that synchronous emission from scheduling a pointless extra check.
  3. Later emissions go to _updateLatestValue, which stores the value and calls markForCheck(). That's what makes async work with OnPush and zoneless change detection: the pipe itself tells Angular "this view has new data".
  4. Same observable on the next check: returns the stored value. No resubscription.
  5. Different observable reference: unsubscribes from the old one, resets to null, subscribes to the new one. This branch is behind the most common async bug (below).
  6. Destroy: ngOnDestroy unsubscribes. For a promise there's nothing to cancel, so the pipe just drops its callbacks. Errors from the observable aren't thrown in the template; they're passed to Angular's application error handler.
Advancedasync pipe component under test

Here's that behaviour proved in a test. UserStore exposes a Subject so the test controls when values arrive.

profile.ts
@Injectable({ providedIn: 'root' })
export class UserStore {
  readonly user$ = new Subject<User>();
}

@Component({
  selector: 'app-profile',
  imports: [AsyncPipe],
  template: `
    @if (store.user$ | async; as user) {
      <p id="name">Hello, {{ user.name }}</p>
    } @else {
      <p id="loading">Loading…</p>
    }
  `,
})
export class Profile {
  protected store = inject(UserStore);
}
Advancedasync pipe subscription lifecycle in a spec
profile.spec.ts
const store = TestBed.inject(UserStore);
const f = TestBed.createComponent(Profile);
expect(store.user$.observed).toBe(false);        // created, but no check yet

await f.whenStable();
expect(store.user$.observed).toBe(true);         // first check subscribed
expect(text(el, '#loading')).toBe('Loading…');  // async returned null

store.user$.next({ name: 'Grace', email: 'g@example.com' });
expect(text(el, '#name')).toBeNull();            // value stored, DOM not updated yet

await f.whenStable();
expect(text(el, '#name')).toBe('Hello, Grace');

f.destroy();
expect(store.user$.observed).toBe(false);        // unsubscribed with the view
What’s happening
  1. createComponent builds the component, but the template hasn't run yet, so nobody is subscribed: observed is false. The subscription is created by the first change detection, not by the constructor.
  2. whenStable() runs the first check. store.user$ | async subscribes and returns null. @if (null; as user) is falsy, so the @else branch renders Loading….
  3. next(...) delivers Grace. _updateLatestValue stores her and calls markForCheck(), but the DOM still shows Loading… — change detection is scheduled, not run synchronously.
  4. The next whenStable() lets that check run. transform returns Grace, @if is truthy, and as user binds the value to a template variable, so the template reads user.name without a non-null assertion.
  5. f.destroy() destroys the view, the pipe's ngOnDestroy unsubscribes, and the Subject reports observed === false. That's the "you don't have to unsubscribe" promise — compare manual subscribe() in a component, where you'd need takeUntilDestroyed.
AdvancedDuplicate subscriptions, new streams, falsy values

Three async pipe traps

1. Each | async is its own subscription. Every usage in the template creates a separate pipe instance, and each one subscribes. With a cold observable — HttpClient calls are cold — that means one request per | async:

twice.html
<!-- store.cold$ is a cold observable that counts its subscribers -->
<p>{{ (store.cold$ | async)?.name }}</p>
<p>{{ (store.cold$ | async)?.email }}</p>       <!-- subscribeCount: 2 -->

@if (store.cold$ | async; as user) {             <!-- subscribeCount: 1 -->
  <p>{{ user.name }}</p>
  <p>{{ user.email }}</p>
}
What’s happening
  1. cold$ is defer(() => { subscribeCount++; return of(user); }), so it runs its setup once per subscriber — like an HTTP call, which sends one request per subscription.
  2. The first template has two | async usages, so Angular creates two AsyncPipe instances, each subscribes, and the count is 2. With a real http.get that's two identical requests.
  3. The second template subscribes once with @if (… | async; as user) and reuses user everywhere inside the block. The count is 1. (@let user = store.cold$ | async; does the same without the @if.)
  4. If several parts of the page really need the same stream, share it at the source with shareReplay({ bufferSize: 1, refCount: true }) — see Hot vs cold observables.

2. Never pass a method that creates a new observable. Because of the "different reference → unsubscribe and resubscribe" branch, a template like {{ (load() | async)?.name }} where load() returns this.http.get(...) or source.pipe(map(...)) subscribes again on every check:

fresh.ts
@Component({
  selector: 'app-fresh',
  imports: [AsyncPipe],
  template: `
    <p>{{ (load() | async)?.name }}</p>
    <button (click)="likes.set(likes() + 1)">like</button>
  `,
})
export class Fresh {
  private store = inject(UserStore);
  likes = signal(0);
  load() {
    return this.store.cold$.pipe(map((u) => u)); // a NEW Observable on every call
  }
}
// After the first render subscribeCount is 1; after three "like" clicks it is 4.
What’s happening
  1. Each check calls load(), and pipe(map(...)) returns a brand-new Observable object every time.
  2. The async pipe sees obj !== this._obj, unsubscribes from the previous one and subscribes to the new one. The test counted 1 subscription after the first render and 4 after three unrelated clicks.
  3. This one only "works" because of() emits synchronously, so each fresh subscription has a value immediately. With an HTTP call, each new subscription starts with null, and when the response arrives markForCheck() triggers another check, which calls load() again, which creates another request… The page flickers or loops and fires request after request.
  4. Fix: create the observable once, as a field (user$ = this.http.get(...)), or use a signal-based API (toSignal, httpResource).

3. Falsy values hide content. @if (count$ | async; as count) treats 0, '' and false the same as "not loaded yet":

zero.html
<!-- count$ = of(0) -->
@if (count$ | async; as count) {
  <p>{{ count }} unread</p>          <!-- never rendered: 0 is falsy -->
}

@let unread = count$ | async;
@if (unread !== null) {
  <p>{{ unread }} unread</p>         <!-- "0 unread" -->
}
What’s happening
  1. count$ emits 0 synchronously, so the async pipe returns 0 on the first check.
  2. @if (0; as count) evaluates the condition first, and 0 is falsy, so the block never renders. The test confirms the first <p> doesn't exist.
  3. @let unread = count$ | async; subscribes once and stores the value (0) in a template variable. @if (unread !== null) then distinguishes "no value yet" (null) from "the value is zero". The test sees 0 unread.
How many HTTP requests?
HTML
<!-- user$ = this.http.get<User>('/api/me') — a field, created once -->
<h1>{{ (user$ | async)?.name }}</h1>
<img [src]="(user$ | async)?.avatarUrl" />
@if ((user$ | async)?.isAdmin) { <app-admin-menu /> }
Show answer

Three.

What’s happening
  1. user$ is created once, so the "new reference" trap doesn't apply — but there are three | async usages, so Angular creates three AsyncPipe instances.
  2. Each instance subscribes on the first check. HttpClient observables are cold: every subscription sends its own request. Three subscriptions, three GET /api/me requests.
  3. Fix: subscribe once — wrap the markup in @if (user$ | async; as user) { … } (or @let user = user$ | async;) and use user.name, user.avatarUrl, user.isAdmin inside. Or convert it once with toSignal(this.http.get(...)) and read user().

#Signals don't need the async pipe

A signal is not a stream you subscribe to; it's a value container you read by calling it. {{ count() }} reads the current value, and because the template read it, Angular records the signal as a dependency of this view. When the signal changes, Angular knows exactly which views to check. There's nothing to subscribe to and nothing to clean up, so the async pipe is only for observables and promises — my notes put it exactly that way.

sig.ts
@Component({
  selector: 'app-sig',
  imports: [JsonPipe],
  template: `
    <p>{{ count() }}</p>
    <pre id="wrong">{{ settings | json }}</pre>
    <pre id="right">{{ settings() | json }}</pre>
    <p>{{ user()?.name ?? 'none yet' }}</p>
  `,
})
export class Sig {
  private store = inject(UserStore);
  count = signal(3);
  settings = signal({ theme: 'dark' });
  user = toSignal(this.store.user$); // Signal<User | undefined>
}
What’s happening
  1. {{ count() }} renders 3. No pipe: calling the signal is the read, and it registers the dependency.
  2. {{ settings | json }} passes the signal function itself to JsonPipe. JSON.stringify of a function is undefined, so #wrong renders an empty string. In dev mode Angular 22 warns in the console: The JsonPipe does not unwrap signals. Received a signal with value: { theme: 'dark' } (keyvalue has the same check).
  3. {{ settings() | json }} calls the signal first and pipes the object, giving the pretty-printed { "theme": "dark" }. Pipes work fine with signals — you just pipe the value.
  4. toSignal(this.store.user$) subscribes to the observable once, when the component is created (it must run in an injection context, like a field initialiser), and unsubscribes when the component is destroyed. Its value is undefined until the first emission — hence ?? 'none yet' — and the template reads it with user() like any other signal. After the test calls user$.next(...), it renders Linus.
  5. Trying {{ count | async }} is a compile error under strict templates: TS2769: No overload matches this call … Argument of type 'WritableSignal<number>' is not assignable to parameter of type 'Observable<unknown> | Subscribable<unknown> | PromiseLike<unknown>'.
AdvancedSignals, observables and the template

So the modern pattern is: keep streams as observables where RxJS operators help (debounce, switchMap), then either use | async once in the template or convert to a signal with toSignal() and read it like any other state. See The async pipe and toSignal() and Signals and RxJS together.

You haveIn the template
signal / computed / input{{ value() }}
Observable or Promise{{ value$ | async }}, or toSignal(value$) then {{ value() }}
A signal whose value needs formatting{{ value() | date }}

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.