A pipe formats a value for display. You call it in a template with |:
<td class="small">{{ row.createdAt | date: 'mediumDate' }}</td>React has no equivalent and does not need one — a JSX template is JavaScript, so
formatDate(row.createdAt) is already available. An Angular template is not JavaScript,
so a formatting function has to be either a method on the component, repeated everywhere that
formats a date, or a pipe: declared once, imported where needed.
The built-in ones worth knowing
DatePipe, CurrencyPipe, DecimalPipe,
PercentPipe, UpperCasePipe, TitleCasePipe,
JsonPipe (useful for debugging), SlicePipe, KeyValuePipe, and
AsyncPipe.
Arguments come after a colon, and pipes chain left to right:
{{ order.createdAt | date: 'mediumDate' }}
{{ name | titlecase | slice: 0 : 20 }}Writing one
A class with a decorator and one method:
@Pipe({ name: 'money' })
export class MoneyPipe implements PipeTransform {
transform(value: number | null | undefined): string {
return formatMoney(value ?? 0);
}
}The name is what the template writes after the bar. The first argument to
transform is the piped value; any further arguments are what follows the colons.
Note the signature accepts null | undefined and defaults to zero. Both happen for
real — an optional field on a DTO, or a total read before its request has landed — and without it the
page prints $NaN.
Angular ships a CurrencyPipe that does very nearly this. MoneyPipe
exists so that both frontends format money through the same formatMoney function and
cannot drift apart on rounding. That is the usual reason to write a pipe that duplicates a built-in:
not formatting, but a single source of truth.
The same file carries a second one for enum values from the API:
@Pipe({ name: 'humanise' })
export class HumanisePipe implements PipeTransform {
transform(value: string | null | undefined): string {
return value ? humanise(value) : '';
}
}PENDING_PAYMENT becomes Pending payment, in the orders table, the
admin screens and the cart alike.
Using one
A pipe is imported by the component whose template uses it, exactly like a child component:
@Component({
selector: 'app-product-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MoneyPipe],Pure and impure
This is the part that matters for performance, and the default is the one you want.
A pure pipe — the default — re-runs only when its input changes. Angular
compares by reference, the way it does for OnPush inputs.
An impure pipe (@Pipe({ name: 'x', pure: false })) re-runs on
every change-detection pass. It is the classic way to make a list slow: put an impure pipe
in a loop over 200 rows and it runs 200 times per pass, for every pass, forever.
The usual reason people reach for impure is that a pure pipe does not notice a mutated array —
items.push(x) does not change the reference, so the pipe does not re-run. The fix is
not an impure pipe. The fix is to stop mutating: replace the array, and everything downstream
notices.
AsyncPipe
| async subscribes to an observable, renders its latest value, and unsubscribes
when the component is destroyed. For years it was how data reached an Angular template.
The pizza app does not use it once. Its state is signals, and a signal is read by calling it —
no subscription to manage, so nothing for async to do. Where an observable does appear,
toSignal converts it at the boundary rather than leaving it for the template.
You will still meet | async constantly in existing code, and it is still correct.
It is simply not the default any more.
When not to use a pipe
For a value derived from component state, a computed is usually better: it is
plain TypeScript, it is testable without a template, and it caches. A pipe earns its place when the
formatting is shared across components — which is exactly the case for money and for
enum names.
What is next
Styling: view encapsulation, :host, and how a Sass theme layers on top of
Bootstrap without a single !important.