I shipped a 90-pipe Angular library, and the thing I want to write about isn't the pipes themselves. It's what happens when you compose them.
ngx-transforms is a standalone Angular pipe library - 90 pipes across 8 categories, lightweight and verified against Angular 17 / 19 / 21 in CI. You can install it today:
npm install ngx-transforms
But this post isn't really a feature dump. It's about something. I noticed three categories in it: the pipes get more useful when they're composed than when they're used alone, and Angular doesn't have a strong cultural pattern for that yet. Most pipe libraries are flat catalogs. I want to push for a different framing: pipes as a composable language for template-side data transformation.
If you stick with me, by the end of this post you'll have six concrete patterns you can lift into your next Angular project.
Why I built this
Every Angular project I've worked on has a pipes/ folder that grows organically. Truncate. Time-ago. Slug from title. Mask the credit card. Format bytes. Group by region. Each one written by hand, in each codebase, with subtle bugs at the edges (what does truncate do with null? what about emoji? what about whitespace input to slugify?).
There are existing pipe libraries, but most of them are NgModule-based artifacts from the Angular 2–14 era. With standalone components stable since Angular 14 and the default since 17, the calculus changed: you can now import a single pipe class directly into a single component without polluting an NgModule. Tree-shaking works. Bundle size doesn't suffer.
So I rewrote what I'd been writing by hand for years, as 90 individually-importable standalone pipes, with tests and a docs site. The library is at 0.3.2 today, deliberately pre-1.0 until real-world adoption surfaces issues I can't predict from in-house testing. (More on that philosophy below.)
What's in the box
8 categories, 90 pipes total:
| Category | Count | A taste |
|---|---|---|
| Text | 27 |
truncate, slugify, latinize, template, wrap
|
| Array | 20 |
groupBy, orderBy, unique, chunk, intersection
|
| Math | 13 |
min, max, sum, average, bytes, percentage
|
| Object | 8 |
keys, pairs, pick, omit, invert, diffObj
|
| Boolean | 8 |
isDefined, isNull, isString, isArray, isEmpty
|
| Data | 5 |
count, timeAgo, jsonPretty, device, textToSpeech
|
| Security | 5 |
htmlSanitize, creditCardMask, emailMask, ipAddressMask
|
| Media | 4 |
qrCode, barcode, gravatar, colorConvert
|
Every pipe is a standalone class. You import it directly:
import { Component } from '@angular/core';
import { TruncatePipe, TimeAgoPipe } from 'ngx-transforms';
@Component({
standalone: true,
imports: [TruncatePipe, TimeAgoPipe],
template: `
<p>{{ post.body | truncate:80 }}</p>
<small>{{ post.createdAt | timeAgo }}</small>
`,
})
export class PostCard {}
Nothing fancy. The interesting part starts when you stop thinking of pipes as standalone utilities and start thinking of them as a chainable language.
The compositional thesis
Here's a sentence I'd put on a poster: Angular templates are a great place to express data transformations, and we've underused them for years.
The reason is cultural, not technical. NgModules made importing pipes annoying enough that most apps imported one or two and wrote the rest as component-class getters or RxJS map chains. Standalone components dissolved that friction. You can now drop three pipes into one component and chain them with no overhead.
When you do that, three things become true:
- The transformation lives next to the markup it controls. A reader sees both the shape of the data and how it renders, in one place.
-
Memoization is free. Pure pipes cache by reference. Angular reuses cached results across change detection cycles automatically, no
computed()needed. - The component class shrinks. Often to zero transformation logic. Just inputs, signals, and event handlers.
That last point is the one that surprised me. By the time I had 60+ pipes, my own components had stopped having transformation methods at all. The class became data; the template became an algorithm. Some people will find that ugly. I find it honest that the template is already where transformation happens conceptually, so making it the literal place too removes a layer of indirection.
Six patterns I'd reach for
The library's docs site has six full recipes, each with a live interactive playground. Here's the abbreviated tour, one pattern per pipe-composition shape.
1. Recursive walker for rendering unknown JSON
You have a value of unknown shape. Render it as a tree.
import { Component, input } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { IsArrayPipe, IsObjectPipe, PairsPipe } from 'ngx-transforms';
@Component({
selector: 'app-tree-node',
standalone: true,
imports: [JsonPipe, IsArrayPipe, IsObjectPipe, PairsPipe, TreeNode], // ← self-import
template: `
@if (value() | isArray) {
<ul>
@for (item of asArray(); track $index) {
<li><app-tree-node [value]="item" /></li>
}
</ul>
} @else if (value() | isObject) {
<dl>
@for (entry of value() | pairs; track entry[0]) {
<dt>{{ entry[0] }}</dt>
<dd><app-tree-node [value]="entry[1]" /></dd>
}
</dl>
} @else {
<span>{{ value() | json }}</span>
}
`,
})
export class TreeNode {
value = input<unknown>(null);
asArray = () => this.value() as unknown[];
}
Three pipes (isArray, isObject, pairs) and a self-importing standalone component. The whole walker is the template. No class methods.
2. Sequential chain to slug from any title
<small>/blog/{{ title | truncate:60:'':true | latinize | slugify }}</small>
Truncate first to preserve word boundaries while spaces still exist, latinize to flatten diacritics to ASCII, slugify to lowercase and replace whitespace. Order matters. The chain reads top-to-bottom like a pipeline diagram.
3. Parallel fan-out — KPI dashboard
One source array, six independent aggregations:
<div class="card">{{ orders | sum:'total' | currency }}</div>
<div class="card">{{ orders | average:'total' | currency }}</div>
<div class="card">{{ orders | max:'total' | currency }}</div>
<div class="card">{{ orders | sum:'fileSizeBytes' | bytes }}</div>
<div class="card">{{ orders | pluck:'customer' | unique | count }}</div>
<div class="card">{{ completed | percentage:orders.length:1 }}%</div>
Pure pipes memoize per chain. The six cards don't recompute unless the input array reference changes and even then, only chains depending on the changed data re-run. You don't pay for the cards you don't visually update.
4. Conditional substitution for avatar fallback
@if (!(user.email | isEmpty)) {
<img [src]="user.email | gravatar:64" />
} @else if (!(user.name | isEmpty)) {
<span class="initials">{{ user.name | initials }}</span>
} @else {
<icon name="user" />
}
isEmpty gates each branch. gravatar and initials handle their respective renders. Adding a fourth fallback (say, a per-tenant company logo) is one @else if block no class changes.
5. Scan-and-transform for PII leak detection
Extract sensitive patterns from unstructured text, render each detection through its mask pipe:
<section>
<h3>Cards ({{ (text | match:cardRe).length }})</h3>
@for (card of text | match:cardRe; track $index) {
<code>{{ card | creditCardMask }}</code>
}
</section>
<section>
<h3>Emails ({{ (text | match:emailRe).length }})</h3>
@for (email of text | match:emailRe; track $index) {
<code>{{ email | emailMask }}</code>
}
</section>
Detection regex says what to find. Mask pipe says how to redact. They evolve independently.
6. Diff-driven UI for dirty-form tracking
One signal holds the original snapshot, another holds the live edits. One pipe expression answers every dirty-state question the UI has:
<pre>{{ current() | diffObj:original() | json }}</pre>
<button [disabled]="(current() | diffObj:original()) | isEmpty">
Save changes
</button>
That diff object IS the PATCH body. Send it as-is to your API. On save success, promote current → original and the form goes clean automatically. No dirty flags, no reducers.
Engineering choices worth talking about
A few things that turned out matter more than I expected.
Tree-shaking, verified
sideEffects: false in the published package.json. Every pipe is a standalone class with no top-level side effects. Modern bundlers tree-shake by default. The library ships as a single FESM bundle at 168 KB raw / 27.9 KB gzipped / 23.3 KB brotli that's the whole thing. Real consumer apps include only the pipes they import, plus the deps those pipes actually need.
Three pipes do pull their own deps and are worth flagging if you care about size: qrCode (qrcode), barcode (jsbarcode), gravatar (js-md5), asciiArt (ts-ascii-engine). The other 86 pipes are pure standalone-pipe code.
Bundle size is enforced in CI via size-limit because every PR runs against fixed thresholds. Regressions fail the build.
Multi-version Angular CI
Claiming "supports Angular 17–21" is cheap; verifying it is the work. A nightly cron in GitHub Actions scaffolds a fresh ng new project at each of Angular 17, 19, and 21, installs the locally-packed library tarball, and runs ng build. If any version breaks, the matrix reports red within 24 hours. That's how you find out the AOT compiler tightened a check between 19 and 20 before a user files an issue.
Type narrowing throughout
Every pipe's transform() signature is (value: unknown, ...args) (or a tighter type when it makes sense). No any. The pipes do runtime guards for null/undefined/wrong-type input and degrade to sensible defaults instead of throwing. That mismatch between TypeScript's compile-time narrowing and JavaScript's runtime reality is where most pipe libraries get sloppy; this one tries hard not to.
Deliberately pre-1.0
The library is at 0.3.2. I'm not in a hurry to cut 1.0.
Here's the reasoning: 1.0 is a promise about API stability under real-world pressure, not an internal "we feel ready" signal. Plenty of libraries have shipped 1.0 prematurely and either had to ship 1.x with breaking changes (eroding trust) or sat frozen at 1.x while everyone migrated to 2.x. I'd rather absorb the discovery period in 0.x where breaking changes are still socially expected.
The 1.0 trigger isn't a calendar date. It's a signal pattern: real consumers reporting non-trivial issues that I fix, a handful of stars, weekly npm downloads in the hundreds, a stretch of weeks without breaking changes. When that emerges organically, the version bump just acknowledges what's already true.
Pure pipes + Signals: The Underrated Combo
A practical note for anyone using signals (you should be, in new code).
Pure pipes and signals are weirdly complementary. The signal triggers change detection when its value changes; the pipe memoizes the transformation result keyed off the input reference. The net effect is that a derived view defined as mySignal() | someChain | json recomputes precisely when it needs to and not a moment more without you writing a single computed().
This is the real reason I think pipes are due for a comeback. Signals fixed Angular's reactivity story, and pure pipes are the lightweight derived-value mechanism that pairs with signals. computed() is great for class-level derived values; pipes are great for template-level ones. Use the right tool for the surface.
Try it / contribute
- Docs + live playground: ngx-transforms.vercel.app
-
npm:
npm install ngx-transforms - Source / issues: github.com/mofirojean/ngx-transforms
If you find a pipe missing, a recipe pattern that should be documented, or a real bug, please file an issue. The discovery period that gates 1.0 lives or dies on real consumer feedback, so even a "I tried to do X and got confused" is genuinely useful.
If you write Angular and want pipes that compose, you have 90 of them now. Six recipes worth of patterns to lift directly. And a maintainer (me) who'd rather hear what's wrong than guess.
Build something with it. Let me know what breaks.
Top comments (0)