Angular Pipes: Every Built-in Pipe, Custom Pipes, and Pure vs Impure [2026]

Link copied
Angular Pipes: Every Built-in Pipe, Custom Pipes, and Pure vs Impure [2026]

Angular Pipes: Every Built-in Pipe, Custom Pipes, and Pure vs Impure [2026]

Pipes are Angular's declarative way to transform a value before it lands on screen. {{ posted | date }}, {{ price | currency }}, {{ items() | async }} — small syntax, big payoff. Every Angular application uses pipes; most teams use a mix of the dozen built-ins plus a few custom ones. This lesson catalogs every pipe Angular ships with, walks the syntax for writing your own, and clarifies the pure-vs-impure distinction that catches new developers.

This is lesson 2.8 of the Angular Tutorial, part of Module 2. By the end you will have the full pipe vocabulary for templates and know exactly when to reach for a custom pipe vs a computed() signal.

The shape #

A pipe applies a transformation between the value and the template, using the | operator:

{{ value | pipeName }}
{{ value | pipeName:arg1:arg2 }}
{{ value | pipeA | pipeB }}

Three facts to anchor everything else:

  1. Pipes are imported via the component's imports array (lesson 2.1). Forget the import and the template compiler fails the build.
  2. Pipes do not mutate — they take a value, return a derived value. The original is untouched.
  3. Pipes are functions, not classes you instantiate. You declare transform() once; Angular calls it with the value plus any arguments.

The built-in pipe table #

Every pipe in @angular/common, in one table:

Pipe Module Example Purpose
date DatePipe `{{ posted date:'medium' }}`
currency CurrencyPipe `{{ price currency:'USD':'symbol':'1.2-2' }}`
decimal (number) DecimalPipe `{{ count number:'1.0-0' }}`
percent PercentPipe `{{ rate percent:'1.0-0' }}`
json JsonPipe `
{{ obj
json }}`
async AsyncPipe `{{ user$ async }}`
keyvalue KeyValuePipe `@for (item of obj keyvalue; track item.key)`
slice SlicePipe `{{ list slice:0:5 }}`
titlecase TitleCasePipe `{{ name titlecase }}`
uppercase UpperCasePipe `{{ code uppercase }}`
lowercase LowerCasePipe `{{ email lowercase }}`
i18nPlural I18nPluralPipe `{{ count i18nPlural:counts }}`
i18nSelect I18nSelectPipe `{{ gender i18nSelect:salutations }}`

The top six (date, currency, decimal, json, async, slice) cover 90% of real-world usage. keyvalue is the one most newcomers miss — it makes iterating an object in a template trivial.

Pipe arguments #

Every built-in pipe takes optional arguments after a colon:

{{ posted | date:'short' }}          <!-- 8/9/24, 4:30 PM -->
{{ posted | date:'medium' }}         <!-- Aug 9, 2024, 4:30:45 PM -->
{{ posted | date:'YYYY-MM-dd' }}     <!-- 2024-08-09 (custom pattern) -->
{{ posted | date:'medium':'+0000':'en-US' }}  <!-- format, timezone, locale -->

Multiple arguments chain with :. Arguments can be string literals, signals, or any template expression:

{{ price | currency:currency():'symbol':digitInfo() }}

Chaining pipes #

Multiple pipes apply left-to-right:

{{ user.name | uppercase | slice:0:3 }}     <!-- First three uppercase letters -->
{{ price | currency | uppercase }}           <!-- $1.99 -> $1.99 (uppercase no-op here) -->
{{ items | slice:0:10 | json }}              <!-- Pretty-print first 10 -->

No limit on chain length, but past 2-3 pipes the expression becomes hard to read — pull the chain into a @let or a computed().

The async pipe — special and important #

async subscribes to an Observable or Promise, renders its current value, and automatically unsubscribes when the component is destroyed. It is the bridge between RxJS code and the template:

import { AsyncPipe } from '@angular/common';
import { fromEvent } from 'rxjs';

@Component({
  selector: 'app-mouse',
  imports: [AsyncPipe],
  template: `<p>X: {{ x$ | async }}</p>`,
})
export class MouseComponent {
  x$ = fromEvent<MouseEvent>(window, 'mousemove').pipe(map(e => e.clientX));
}

Three behaviors:

  • Renders null before the first emission. Use @if (x$ | async; as x) to narrow.
  • Unsubscribes automatically on destroy. No takeUntilDestroyed() needed.
  • Resubscribes if the template re-binds. A [ngIf] that recreates the host re-subscribes.

In 2026 signal-first code, async is fading because most state is a signal (which the template handles natively). You still need async when working with HttpClient (which returns an Observable) or any other RxJS source — but signal interop (toSignal(), lesson 7.3) is the cleaner long-term answer.

Custom pipes — the shape #

A custom pipe is a class with @Pipe({...}) and a transform() method:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
  transform(value: string, maxLength: number = 50, suffix: string = '...'): string {
    if (value.length <= maxLength) return value;
    return value.slice(0, maxLength) + suffix;
  }
}

Three things:

  1. name is the template name. {{ value | truncate }}. The class name is irrelevant.
  2. transform() is required. It takes the value as first argument, then the arguments from the template after.
  3. No standalone: true needed. Standalone is default since v19.

Use the pipe by adding it to a component's imports:

@Component({
  imports: [TruncatePipe],
  template: `<p>{{ longText | truncate:80 }}</p>`,
})

Pure vs impure pipes #

A pure pipe (the default) runs transform() ONLY when the input reference changes — not on every change-detection cycle. This is what makes pipes fast.

@Pipe({ name: 'pure' })   // pure is the default
export class PurePipe implements PipeTransform {
  transform(items: Item[]): Item[] {
    console.log('pure transform ran');
    return items.filter(i => i.active);
  }
}

If items is the SAME array reference between CD cycles, the transform doesn't re-run — the cached result is returned. If the array reference changes (a new array), the transform re-runs.

An impure pipe runs transform() on EVERY change-detection cycle, regardless of reference:

@Pipe({ name: 'impure', pure: false })
export class ImpurePipe implements PipeTransform {
  transform(items: Item[]): Item[] {
    console.log('impure transform ran');
    return items.filter(i => i.active);
  }
}

Impure pipes are the right answer when:

  • Your pipe depends on hidden state (localized time-of-day, random selection)
  • The same input must re-compute (rare — usually a code smell)
  • You're maintaining legacy code that mutates arrays in place (the migration target is to stop mutating)

For 99% of pipes, pure is correct. Impure is an escape hatch.

Pure pipes vs computed() signals #

In 2026, when you find yourself reaching for a custom pure pipe, ask whether a computed() signal would be cleaner:

// Pipe approach
@Pipe({ name: 'activeUsers' })
export class ActiveUsersPipe implements PipeTransform {
  transform(users: User[]): User[] {
    return users.filter(u => u.active);
  }
}
// Usage: {{ users() | activeUsers }}

// computed() approach
export class UserListComponent {
  users = input.required<User[]>();
  activeUsers = computed(() => this.users().filter(u => u.active));
}
// Usage: {{ activeUsers() }}

The computed() approach wins when:

  • The derivation is specific to this component (not shared across screens)
  • You want automatic memoization keyed on the signal graph
  • The derived value will be read multiple times in the same template

A pipe wins when:

  • The transformation is shared across many components (define once, import everywhere)
  • The data isn't a signal yet (e.g., a raw RxJS observable)
  • Template clarity matters more than slight perf overhead

Date formatting and currency are pure-pipe territory forever. Component-specific filtering and sorting are increasingly computed() territory.

Locale-aware pipes #

date, currency, decimal, percent are all locale-aware. By default they use the locale registered via LOCALE_ID. In a globalized app, register the locales you need at bootstrap:

import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import localeJa from '@angular/common/locales/ja';

registerLocaleData(localeFr);
registerLocaleData(localeJa);

Then pass the locale to the pipe:

{{ posted | date:'medium':undefined:'fr-FR' }}

The locale argument trades convenience for per-call control. For app-wide locale, set LOCALE_ID in app.config.ts providers and skip the per-pipe argument.

Common gotchas #

Symptom Cause Fix
The pipe 'foo' could not be found Forgot to import the pipe class Add it to the component's imports array
Pure pipe doesn't update when array changes You mutated the array in place (.push()) instead of replacing the reference Use immutable updates: items.set([...items(), newItem])
Date pipe shows wrong locale LOCALE_ID not set Set in app.config.ts providers or pass locale arg per pipe call
async pipe shows null flicker on first render Observable hasn't emitted yet Use `(value$
Impure pipe causes slow rendering Pipe re-runs on every CD cycle Make it pure (default) — only set pure: false if you have a specific reason

A real custom pipe — truncate #

A complete, useful custom pipe used in production codebases:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
  transform(
    value: string | null | undefined,
    maxLength: number = 80,
    suffix: string = '…',
    wordBoundary: boolean = true,
  ): string {
    if (!value) return '';
    if (value.length <= maxLength) return value;

    let truncated = value.slice(0, maxLength);
    if (wordBoundary) {
      const lastSpace = truncated.lastIndexOf(' ');
      if (lastSpace > 0) truncated = truncated.slice(0, lastSpace);
    }
    return truncated + suffix;
  }
}

Usage:

<p>{{ article.body | truncate }}</p>                <!-- 80 chars, word boundary -->
<p>{{ article.body | truncate:120 }}</p>            <!-- 120 chars -->
<p>{{ article.body | truncate:120:' [...]' }}</p>   <!-- custom suffix -->
<p>{{ article.body | truncate:120:'…':false }}</p>  <!-- ignore word boundary -->

This kind of helper is the bread-and-butter case for a custom pipe — small, reusable, no component-specific logic.

What's next #

Lesson 2.9 covers directives — the cousins of components that attach behavior to existing elements (attribute directives) and rewrite template structure (structural directives). Lesson 2.10 explains <ng-template> and <ng-container> — the building blocks of structural directives. Lesson 2.11 walks styling and view encapsulation.

Try it yourself #

A component that uses three built-in pipes plus a chained transformation:

import { Component, signal } from '@angular/core';
import { CurrencyPipe, DatePipe, JsonPipe, AsyncPipe } from '@angular/common';

@Component({
  selector: 'app-receipt',
  imports: [CurrencyPipe, DatePipe, JsonPipe],
  template: `
    <h2>Receipt #{{ id() }}</h2>
    <p>Date: {{ purchasedAt() | date:'medium' }}</p>
    <p>Total: {{ total() | currency:'USD' }}</p>
    <pre>{{ items() | json }}</pre>
  `,
})
export class ReceiptComponent {
  id = signal('R-12345');
  purchasedAt = signal(new Date());
  total = signal(99.95);
  items = signal([{ name: 'Widget', qty: 2 }, { name: 'Gadget', qty: 1 }]);
}

Three pipes, three imports, one template. Drop the JsonPipe line during development to verify shape; remove it before shipping.

YouShould I write a custom pipe to filter a list, or use computed()?
Claude · used get_best_practicesFor filtering used in one component, use computed() — it memoizes automatically based on the signal graph, the template stays simple, and there’s no extra class to maintain. For filtering used across many components (e.g., a generic truncate or safeUrl), a pure pipe wins because you define it once and import it everywhere. Pure pipes are also slightly faster than computed() for very large arrays since they skip the dependency-tracking overhead.

Lesson 2.9 picks up directives — the other major Angular template construct.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *