Angular Pipes: Built-in Formatting, Custom Pipes, and Purity Explained
Every app renders the same raw data many ways: paise as โน amounts, ISO timestamps as โ2 hours agoโ, enums as human labels, long strings truncated with ellipses. Pipes are Angularโs answer โ small, declarative transforms applied in the template, at the point of display:
{{ order.totalPaise / 100 | currency:'INR' }} โ โน4,999.00{{ order.placedAt | date:'dd MMM, h:mm a' }} โ 19 Jul, 4:30 PMThey look like a garnish; they encode a real architectural stance โ display formatting belongs to the view layer, not the data. This guide covers the built-ins, writing your own, the purity/memoization model that makes pipes fast, and the honest comparison with computed signals now that both exist.
The Pipe Idea, and Why It Beats Alternatives
The | syntax chains a value through named transforms, left to right, with : for arguments:
{{ user.name | uppercase }}{{ price | currency:'INR':'symbol':'1.0-0' }} <!-- multiple args -->{{ order.note | slice:0:80 | lowercase }} <!-- chaining -->Consider the alternatives for the same job. Formatting in the component class (this.displayPrice = format(price)) duplicates state โ now the raw and formatted values must be kept in sync, the stale-derived-state disease. Formatting in the data model (storing "โน4,999.00" from the API) welds presentation into data, breaking the moment a second view needs a different format or locale. Template method calls ({{ format(price) }}) work but re-execute every change-detection pass. Pipes dodge all three: raw data stays raw, formatting is declared at the point of use, and purity (below) gives memoization for free.
The Built-Ins Worth Knowing Cold
{{ paise / 100 | currency:'INR' }} <!-- โน formatting, locale-aware -->{{ 0.1846 | percent:'1.1-1' }} <!-- 18.5% -->{{ views | number:'1.0-0' }} <!-- 1,24,567 (locale grouping!) -->{{ ts | date:'mediumDate' }} <!-- 19 Jul 2026 -->{{ name | titlecase }} {{ code | uppercase }}{{ description | slice:0:120 }}{{ debugObject | json }} <!-- dev-time object dumps -->The digit-info mini-language ('1.0-2' = min 1 integer digit, 0โ2 decimals) covers most numeric-precision needs. Two things elevate this from trivia:
Locale awareness is built in. currency, number, date, and percent all format per the appโs configured locale โ Indian digit grouping (1,24,567), month names, currency symbols. Apps that hand-roll formatting with string concatenation rediscover locale bugs one market at a time; pipes route through the i18n machinery from day one. (One sharp edge inherited from JS itself: date accepts ISO strings, but store and transport timestamps properly โ parsing ambiguity is upstream of any pipe.)
The async pipe โ historically the most important pipe in Angular โ subscribes to an Observable/Promise in the template and unsubscribes automatically:
@if (user$ | async; as user) { {{ user.name }} }In signal-forward code its role shrinks (signals are read directly), but it remains the bridge wherever Observables surface, and a decade of codebases lean on it โ full treatment in the RxJS guide.
Writing Custom Pipes
The candidates announce themselves โ a formatting rule appearing in three templates. Structure:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'timeAgo' })export class TimeAgoPipe implements PipeTransform { transform(value: string | Date): string { const then = new Date(value).getTime(); const mins = Math.round((Date.now() - then) / 60_000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins} min ago`; const hrs = Math.round(mins / 60); if (hrs < 24) return `${hrs} hr ago`; return `${Math.round(hrs / 24)} d ago`; }}{{ comment.createdAt | timeAgo }} <!-- after importing TimeAgoPipe -->A pipe is a class with one method โ transform(value, ...args) โ pure-function discipline in a decorator. Design notes from production pipes:
- Handle the nullish cases โ templates feed pipes whatever the data has:
if (value == null) return '';beats aTypeErrorin the view. (Nullish, not falsy โ0is usually formattable.) - Parameters make one pipe serve many callers:
transform(value: number, decimals = 2)โ{{ x | paiseToRupees:0 }}. - Keep transforms cheap and pure โ no service calls, no mutation, no randomness. A pipe that fetches is a design error; a pipe that mutates its input corrupts shared references.
- Testing is trivially pleasant:
new TimeAgoPipe().transform(fiveMinAgo)โ assert. No TestBed, no DOM. That testability is a quiet argument for extracting formatting into pipes.
The timeAgo example carries one instructive flaw: time moves, but the pipe re-runs only when its input changes (โpurity,โ next section) โ so โ2 min agoโ can silently stay stale. Real implementations pair the pipe with a ticking signal input or interval-driven refresh โ a perfect illustration that pipes model pure functions of their inputs, and time is an input you must make explicit.
Purity: The Performance Model
By default pipes are pure: Angular re-invokes transform only when the input value (or an argument) changes by reference. Same input โ cached output, skipped work โ thousands of bindings stay cheap because unchanged rows donโt re-format.
The classic gotcha follows directly: mutate an array/object input and a pure pipe wonโt notice โ the reference didnโt change:
this.items().push(newItem); // sorted-by-pipe list doesn't update!this.items.update((xs) => [...xs, newItem]); // new reference โ pipe re-runs โIf your codebase follows immutable-update discipline โ as signal-era Angular pushes you to โ purity is free correctness. The escape hatch, pure: false, re-runs the pipe every change-detection cycle: occasionally legitimate (the built-in impure async pipe manages subscriptions), usually a smell that either the data flow should be immutable or the transform belongs in a computed.
Pipes vs computed signals โ the modern judgment call
Both memoize derived values; the split thatโs emerging as idiomatic:
computedin the class for component-specific derivations โ totals, filtered lists, view-model shaping โ especially when used multiple places or fed to children.- Pipes for reusable, app-wide formatting vocabulary โ dates, money, truncation, labels โ the transforms every template should share. A pipe is importable everywhere; a computed lives with its component.
- Avoid the worst of both: formatting logic duplicated across componentsโ computeds (should be a pipe), or a โpipeโ so entangled with one componentโs state it canโt be reused (should be a computed).
Building the Projectโs Formatting Vocabulary
The compounding value of pipes arrives when a team treats them as a curated set rather than ad-hoc utilities. A realistic core vocabulary for a commerce app, and the thinking behind each:
{{ order.totalPaise | inr }} // wraps currency:'INR' + the /100 convention โ // the paise-vs-rupees bug becomes unwritable{{ order.placedAt | timeAgo }} // relative time, one implementation{{ order.status | orderStatusLabel }} // enum โ human copy: 'PARTIALLY_SHIPPED' โ 'Partially shipped'{{ sku | truncateMiddle:12 }} // 'KB-MECHโฆ-BLK' โ ends matter for SKUs{{ user.phone | maskPhone }} // '98โขโขโขโขโข210' โ privacy display rule, centralizedTwo of these illustrate design points worth stealing. The inr pipe encodes a convention โ the codebase stores money in paise (as it should), and by owning the division and formatting in one place, the class of off-by-100 display bugs from the self-check simply canโt be written. The orderStatusLabel pipe centralizes copy โ when marketing renames a status, one map changes, and (crucially for i18n later) thereโs exactly one seam where translation slots in.
Team practices that keep the vocabulary healthy: pipes live in one shared/pipes folder with tests beside them (each test file is a specification of the format โ genuinely useful documentation); new formatting logic in a component triggers the review question โexisting pipe, new pipe, or computed?โ; and pipe names are unprefixed domain words (inr, timeAgo) since selector-collision risk is low and template readability is the whole point. A mature appโs templates read like the domain โ {{ order.totalPaise | inr }} needs no comment โ and that legibility is pipes doing their quiet architectural work.
Frequently Asked Questions
How do pipes work with the new control flow and signals? Seamlessly โ {{ items() | slice:0:5 }} reads a signal and pipes the value; @for (x of filtered() | ...) composes too. Pipes transform values; where the value came from is irrelevant.
Can pipes take other pipesโ output? Thatโs chaining โ | slice:0:80 | uppercase โ evaluated left to right, each transform feeding the next, function composition in template clothing.
Why does my date pipe show the wrong day near midnight? Timezones โ the pipe formats in the viewerโs zone by default (an explicit zone is the optional third argument). Store UTC, format locally, and test boundary times; the bug is almost never the pipe.
Is there a pipe for sorting/filtering lists? Deliberately not built in โ the old AngularJS filters caused per-cycle re-sorts of big lists. Sort/filter in a computed (memoized, explicit) and keep pipes for formatting. Writing your own sort pipe is possible and usually the wrong call.
How do standalone pipes get used? Like components: imports: [TimeAgoPipe] in whatever componentโs template uses them. Group your appโs formatting pipes in one folder; they become the projectโs display vocabulary.
Locale Configuration: Making the Built-Ins Speak Your Market
Since the built-insโ locale-awareness was a headline claim, the two-minute setup that activates it: Angular ships with en-US data only; other locales register explicitly:
import { registerLocaleData } from '@angular/common';import localeEnIn from '@angular/common/locales/en-IN';
registerLocaleData(localeEnIn);// app.config.ts providers:{ provide: LOCALE_ID, useValue: 'en-IN' }With en-IN active, {{ 12345678 | number }} renders 1,23,45,678 (lakh/crore grouping), date uses day-month ordering, and currency defaults sensibly โ every pipe in the app, one configuration. Per-call overrides remain (| currency:'USD':'symbol':'1.2-2':'en-US') for multi-currency displays. Two gotchas from the field: forgetting registerLocaleData produces a runtime error naming the missing locale (clear, at least), and mixing hard-coded formatting (toFixed, manual comma insertion) with pipe formatting produces the subtly inconsistent UI that locale users spot instantly โ once the pipes own formatting, let them own all of it. Full multi-language i18n (translated text, not just formats) is a separate machinery (@angular/localize or runtime libraries) โ but it plugs into exactly the seams this guide built: pipes and centralized label maps.
Worth one closing sentence each: pipes work anywhere template expressions do โ inside [input] bindings ([items]="orders() | slice:0:5"), @if conditions, and @for sources, not just interpolation; and a pipe throwing inside a template surfaces as a rendering error naming the pipe, which is why the nullish-guarding habit is really an error-budget decision โ a formatting failure should degrade to empty, not take the view down with it.
Self-Check
{{ order.paise | currency:'INR' }}displays โน499,900.00 for a โน4,999 order. Whatโs the bug? (Hint: itโs not the pipe.)- A
filterActivepipe on a list stops updating when itemsโactiveflags are toggled via mutation. Explain via purity, and give both fixes. - Why is
{{ heavyFormat(row.value) }}in a 500-row table worse than{{ row.value | heavyFormat }}? - You need โtruncate to N chars with ellipsisโ in six components. Pipe or computed โ and why?
- What makes pipes so easy to unit test compared to components?
(Answers: paise passed as rupees โ divide by 100 first (or better: a paiseToInr pipe encoding the convention once). Pure pipes re-run on reference change; mutation keeps the reference โ fix with immutable updates (update + spread) or, less well, pure: false. The method re-runs for all rows every CD cycle; the pure pipe re-runs only for rows whose value reference changed. Pipe โ cross-component display vocabulary, parameterized by N. Theyโre pure transform functions โ instantiate, call, assert; no rendering machinery.)
That closes the components arc. Next section โ the application backbone, starting with Services and Dependency Injection: where non-UI logic lives, inject(), provider scopes, and why DI is the feature enterprises quietly love most.