Advanced pipes extend Angular’s template transformation system with application-specific logic. An impure RelativeTimePipe automatically updates “3 minutes ago” displays without component re-renders. A HighlightSearchPipe marks search terms in text using HTML — but requires DomSanitizer to mark the output as safe. A BytesPipe converts raw byte counts to human-readable strings. These pipes encapsulate formatting logic that would otherwise bloat components or be duplicated across templates.
Production Pipe Implementations
import { Pipe, PipeTransform, inject, ChangeDetectorRef,
OnDestroy } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { interval } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// ── RelativeTimePipe — "3 hours ago", auto-updates every minute ───────────
@Pipe({
name: 'relativeTime',
standalone: true,
pure: false, // impure: re-evaluates on every change detection cycle
})
export class RelativeTimePipe implements PipeTransform, OnDestroy {
private cd = inject(ChangeDetectorRef);
private destroyRef = inject(DestroyRef);
private timer?: ReturnType<typeof setInterval>;
constructor() {
// Update displays every 60 seconds automatically
interval(60_000).pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.cd.markForCheck());
}
ngOnDestroy(): void {
clearInterval(this.timer);
}
transform(value: string | null | undefined): string {
if (!value) return '';
const diffMs = Date.now() - new Date(value).getTime();
const diffSecs = Math.floor(diffMs / 1000);
if (diffSecs < 60) return 'just now';
if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)}m ago`;
if (diffSecs < 86400) return `${Math.floor(diffSecs / 3600)}h ago`;
if (diffSecs < 604800) return `${Math.floor(diffSecs / 86400)}d ago`;
return new Date(value).toLocaleDateString();
}
}
// ── HighlightSearchPipe — marks search terms in HTML ──────────────────────
@Pipe({
name: 'highlight',
standalone: true,
pure: true, // pure — recomputes only when inputs change
})
export class HighlightSearchPipe implements PipeTransform {
private sanitizer = inject(DomSanitizer);
transform(text: string | null, search: string | null): SafeHtml {
if (!text) return '';
if (!search) return text;
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escaped})`, 'gi');
const highlighted = text.replace(regex,
'<mark class="search-highlight">$1</mark>'
);
// bypassSecurityTrustHtml — we control the HTML construction, not user input
return this.sanitizer.bypassSecurityTrustHtml(highlighted);
}
}
// Usage in template: <p [innerHTML]="post.title | highlight:searchQuery"></p>
// ── BytesPipe — converts raw bytes to human-readable ─────────────────────
@Pipe({ name: 'bytes', standalone: true, pure: true })
export class BytesPipe implements PipeTransform {
transform(bytes: number | null, decimals = 1): string {
if (bytes === null || bytes === 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIdx = -1;
while (value >= 1024 && unitIdx < units.length - 1) {
value /= 1024;
unitIdx++;
}
return `${value.toFixed(decimals)} ${units[unitIdx]}`;
}
}
// ── TruncatePipe — truncates text to word boundary ────────────────────────
@Pipe({ name: 'truncate', standalone: true, pure: true })
export class TruncatePipe implements PipeTransform {
transform(text: string | null, maxLength = 150, ellipsis = '...'): string {
if (!text || text.length <= maxLength) return text ?? '';
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return (lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated) + ellipsis;
}
}
// ── Pipe composition in templates ─────────────────────────────────────────
// <p>{{ post.body | truncate:200 | uppercase }}</p>
// <p [innerHTML]="post.excerpt | truncate:100 | highlight:query"></p>
// <span>{{ file.size | bytes:2 }}</span> → "1.24 MB"
// <time>{{ post.publishedAt | relativeTime }}</time> → "3h ago"
DomSanitizer.bypassSecurityTrustHtml() marks HTML as safe to render — bypassing Angular’s XSS sanitisation. This is only safe when you construct the HTML and user input is properly escaped before insertion. In HighlightSearchPipe, the user’s search term is escaped with .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') before being put into the regex, and the text content is inserted as-is without HTML injection risk. Never call bypassSecurityTrustHtml() with unprocessed user-provided content.pure: false) re-execute on every change detection cycle — potentially dozens of times per second. Use them sparingly and keep their logic fast. RelativeTimePipe is impure because its output changes over time even with the same input. To limit execution cost, consider using computed() signals for derived values in the component instead of impure pipes — Signals only recompute when their dependencies change, which is far more efficient than impure pipe re-execution on every change detection sweep.computed() signal or a stored array that only updates when the source data changes. The async pipe is the only impure pipe that is acceptable as a general-purpose pattern — all others should be used only when the output genuinely changes with time independently of input changes.Common Mistakes
Mistake 1 — Using impure pipe for sorting/filtering (performance disaster)
❌ Wrong — posts | sortBy:'publishedAt' with impure pipe; creates new array every render cycle.
✅ Correct — compute sorted array in component as Signal: sortedPosts = computed(() => [...posts()].sort(...)).
Mistake 2 — Not escaping user input in HighlightSearchPipe (XSS risk)
❌ Wrong — inserting raw search term into regex without escaping; user types (.*) which matches everything.
✅ Correct — always escape regex special characters from user input before building the regex.