Skip to main content

Exercise 4: Custom Pipes

Objective

Build custom pipes to transform data in Angular templates, understanding both pure and impure pipes.

Prerequisites

  • Understanding of Angular pipes
  • Knowledge of TypeScript
  • Familiarity with RxJS (for async examples)

Setup

ng new pipe-workshop --standalone --routing=false --style=css
cd pipe-workshop
ng serve

Part 1: Basic Custom Pipes

Task 1.1: Truncate Text Pipe

Create a pipe that truncates long text with ellipsis.

ng generate pipe pipes/truncate --standalone
// src/app/pipes/truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'truncate',
standalone: true
})
export class TruncatePipe implements PipeTransform {
transform(
value: string,
limit: number = 50,
completeWords: boolean = false,
ellipsis: string = '...'
): string {
if (!value) return '';
if (value.length <= limit) return value;

if (completeWords) {
limit = value.substring(0, limit).lastIndexOf(' ');
}

return value.substring(0, limit) + ellipsis;
}
}

// Usage
<p>{{ longText | truncate:100 }}</p>
<p>{{ longText | truncate:50:true }}</p>
<p>{{ longText | truncate:30:false:'---' }}</p>

Task 1.2: Time Ago Pipe

Create a pipe that converts dates to relative time (e.g., "2 hours ago").

// src/app/pipes/time-ago.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'timeAgo',
standalone: true
})
export class TimeAgoPipe implements PipeTransform {
transform(value: Date | string | number): string {
if (!value) return '';

const date = new Date(value);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);

if (seconds < 5) return 'just now';

const intervals: { [key: string]: number } = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60,
second: 1
};

for (const [name, secondsInInterval] of Object.entries(intervals)) {
const interval = Math.floor(seconds / secondsInInterval);

if (interval >= 1) {
return interval === 1
? `1 ${name} ago`
: `${interval} ${name}s ago`;
}
}

return 'just now';
}
}

// Usage
<p>Posted {{ post.createdAt | timeAgo }}</p>
<!-- Output: Posted 2 hours ago -->

Task 1.3: File Size Pipe

Create a pipe that formats bytes into human-readable file sizes.

// src/app/pipes/file-size.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'fileSize',
standalone: true
})
export class FileSizePipe implements PipeTransform {
transform(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 Bytes';

const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];

const i = Math.floor(Math.log(bytes) / Math.log(k));

return (
parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
);
}
}

// Usage
<p>{{ 1536 | fileSize }}</p> <!-- 1.5 KB -->
<p>{{ 1048576 | fileSize:0 }}</p> <!-- 1 MB -->
<p>{{ 134217728 | fileSize:3 }}</p> <!-- 128.000 MB -->

Task 1.4: Phone Number Pipe

Format phone numbers into a readable format.

// src/app/pipes/phone-number.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'phoneNumber',
standalone: true
})
export class PhoneNumberPipe implements PipeTransform {
transform(value: string | number, format: 'US' | 'international' = 'US'): string {
if (!value) return '';

const cleaned = ('' + value).replace(/\D/g, '');

if (format === 'US') {
const match = cleaned.match(/^(\d{3})(\d{3})(\d{4})$/);
if (match) {
return '(' + match[1] + ') ' + match[2] + '-' + match[3];
}
} else {
const match = cleaned.match(/^(\d{1,3})(\d{3})(\d{3})(\d{4})$/);
if (match) {
return '+' + match[1] + ' ' + match[2] + ' ' + match[3] + ' ' + match[4];
}
}

return value.toString();
}
}

// Usage
<p>{{ '1234567890' | phoneNumber }}</p> <!-- (123) 456-7890 -->
<p>{{ '11234567890' | phoneNumber:'international' }}</p> <!-- +1 123 456 7890 -->

Part 2: Array and Object Pipes

Task 2.1: Filter Pipe

Create a pipe that filters arrays based on search criteria.

// src/app/pipes/filter.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'filter',
standalone: true,
pure: false, // Make it impure for dynamic filtering
})
export class FilterPipe implements PipeTransform {
transform\<T\>(items: T[], searchText: string, property?: keyof T): T[] {
if (!items || !searchText) {
return items;
}

searchText = searchText.toLowerCase();

return items.filter(item => {
if (property) {
const value = String(item[property]).toLowerCase();
return value.includes(searchText);
} else {
const itemString = JSON.stringify(item).toLowerCase();
return itemString.includes(searchText);
}
});
}
}

// Usage
@Component({
template: `
<input [(ngModel)]="searchTerm" placeholder="Search..." />
<ul>
@for (user of users | filter:searchTerm:'name'; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
`,
})
export class ListComponent {
searchTerm = '';
users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
}

Task 2.2: Sort Pipe

Create a pipe that sorts arrays.

// src/app/pipes/sort.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'sort',
standalone: true
})
export class SortPipe implements PipeTransform {
transform\<T\>(
items: T[],
property: keyof T,
order: 'asc' | 'desc' = 'asc'
): T[] {
if (!items || !property) {
return items;
}

return [...items].sort((a, b) => {
const aValue = a[property];
const bValue = b[property];

let comparison = 0;

if (aValue > bValue) {
comparison = 1;
} else if (aValue < bValue) {
comparison = -1;
}

return order === 'desc' ? comparison * -1 : comparison;
});
}
}

// Usage
<select [(ngModel)]="sortOrder">
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>

<ul>
@for (product of products | sort:'price':sortOrder; track product.id) {
<li>{{ product.name }} - ${{ product.price }}</li>
}
</ul>

Task 2.3: Group By Pipe

Group array items by a property.

// src/app/pipes/group-by.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'groupBy',
standalone: true,
})
export class GroupByPipe implements PipeTransform {
transform\<T\>(items: T[], property: keyof T): { key: any; items: T[] }[] {
if (!items || !property) {
return [];
}

const grouped = items.reduce((acc, item) => {
const key = item[property];
if (!acc[key as any]) {
acc[key as any] = [];
}
acc[key as any].push(item);
return acc;
}, {} as { [key: string]: T[] });

return Object.keys(grouped).map(key => ({
key,
items: grouped[key],
}));
}
}

// Usage
@Component({
template: `
@for (group of products | groupBy:'category'; track group.key) {
<div class="category-group">
<h3>{{ group.key }}</h3>
<ul>
@for (product of group.items; track product.id) {
<li>{{ product.name }}</li>
}
</ul>
</div>
}
`,
})
export class ProductListComponent {
products = [
{ id: 1, name: 'Laptop', category: 'Electronics' },
{ id: 2, name: 'Phone', category: 'Electronics' },
{ id: 3, name: 'Shirt', category: 'Clothing' },
];
}

Part 3: Advanced Pipes

Task 3.1: Highlight Search Pipe

Highlight matching text in search results.

// src/app/pipes/highlight.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';

@Pipe({
name: 'highlight',
standalone: true,
})
export class HighlightPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}

transform(
value: string,
search: string,
className: string = 'highlight'
): SafeHtml {
if (!search || !value) {
return value;
}

const regex = new RegExp(search, 'gi');
const highlighted = value.replace(
regex,
match => `<span class="${className}">${match}</span>`
);

return this.sanitizer.bypassSecurityTrustHtml(highlighted);
}
}

// Usage
@Component({
template: `
<input [(ngModel)]="searchTerm" placeholder="Search..." />
<p [innerHTML]="text | highlight : searchTerm"></p>
`,
styles: [
`
::ng-deep .highlight {
background-color: yellow;
font-weight: bold;
}
`,
],
})
export class SearchComponent {
searchTerm = '';
text = 'Angular is a platform for building web applications.';
}

Task 3.2: Safe HTML/URL Pipe

Create pipes to safely bypass Angular's security for trusted content.

// src/app/pipes/safe.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml, SafeResourceUrl, SafeScript, SafeStyle, SafeUrl } from '@angular/platform-browser';

@Pipe({
name: 'safe',
standalone: true
})
export class SafePipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}

transform(
value: string,
type: 'html' | 'style' | 'script' | 'url' | 'resourceUrl'
): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl {
switch (type) {
case 'html':
return this.sanitizer.bypassSecurityTrustHtml(value);
case 'style':
return this.sanitizer.bypassSecurityTrustStyle(value);
case 'script':
return this.sanitizer.bypassSecurityTrustScript(value);
case 'url':
return this.sanitizer.bypassSecurityTrustUrl(value);
case 'resourceUrl':
return this.sanitizer.bypassSecurityTrustResourceUrl(value);
default:
return value;
}
}
}

// Usage
<div [innerHTML]="htmlContent | safe:'html'"></div>
<iframe [src]="videoUrl | safe:'resourceUrl'"></iframe>

Task 3.3: Memoize Pipe

Create a pipe that caches expensive computations.

// src/app/pipes/memoize.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'memoize',
standalone: true,
})
export class MemoizePipe implements PipeTransform {
private cache = new Map<string, any>();

transform<T, R>(value: T, fn: (value: T) => R, ...args: any[]): R {
const key = JSON.stringify({ value, args });

if (this.cache.has(key)) {
console.log('Cache hit!');
return this.cache.get(key);
}

console.log('Computing...');
const result = fn(value);
this.cache.set(key, result);

return result;
}
}

// Usage
@Component({
template: ` <p>{{ data | memoize : expensiveCalculation }}</p> `,
})
export class Component {
data = [1, 2, 3, 4, 5];

expensiveCalculation(numbers: number[]): number {
// Simulate expensive operation
return numbers.reduce((sum, n) => sum + n, 0);
}
}

Part 4: Utility Pipes

Task 4.1: Default Value Pipe

Provide default value for null/undefined.

// src/app/pipes/default.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'default',
standalone: true
})
export class DefaultPipe implements PipeTransform {
transform\<T\>(value: T | null | undefined, defaultValue: T): T {
return value ?? defaultValue;
}
}

// Usage
<p>{{ user.phone | default:'No phone number' }}</p>
<p>{{ user.age | default:0 }}</p>

Task 4.2: Pluralize Pipe

Automatically pluralize words based on count.

// src/app/pipes/pluralize.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'pluralize',
standalone: true
})
export class PluralizePipe implements PipeTransform {
transform(
count: number,
singular: string,
plural?: string,
includeCount: boolean = true
): string {
const pluralForm = plural || singular + 's';
const word = count === 1 ? singular : pluralForm;
return includeCount ? `${count} ${word}` : word;
}
}

// Usage
<p>{{ 1 | pluralize:'item' }}</p> <!-- 1 item -->
<p>{{ 5 | pluralize:'item' }}</p> <!-- 5 items -->
<p>{{ 1 | pluralize:'person':'people' }}</p> <!-- 1 person -->
<p>{{ 3 | pluralize:'person':'people' }}</p> <!-- 3 people -->

Task 4.3: Mask Pipe

Mask sensitive data (e.g., credit cards, SSN).

// src/app/pipes/mask.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'mask',
standalone: true
})
export class MaskPipe implements PipeTransform {
transform(
value: string,
visibleDigits: number = 4,
maskChar: string = '*'
): string {
if (!value) return '';

const length = value.length;
if (length <= visibleDigits) return value;

const masked = maskChar.repeat(length - visibleDigits);
const visible = value.substring(length - visibleDigits);

return masked + visible;
}
}

// Usage
<p>Credit Card: {{ '4532123456789012' | mask:4 }}</p>
<!-- Output: ************9012 -->

<p>SSN: {{ '123456789' | mask:4:'X' }}</p>
<!-- Output: XXXXX6789 -->

Complete Demo Application

// src/app/app.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TruncatePipe } from './pipes/truncate.pipe';
import { TimeAgoPipe } from './pipes/time-ago.pipe';
import { FileSizePipe } from './pipes/file-size.pipe';
import { PhoneNumberPipe } from './pipes/phone-number.pipe';
import { FilterPipe } from './pipes/filter.pipe';
import { SortPipe } from './pipes/sort.pipe';
import { GroupByPipe } from './pipes/group-by.pipe';
import { HighlightPipe } from './pipes/highlight.pipe';
import { SafePipe } from './pipes/safe.pipe';
import { DefaultPipe } from './pipes/default.pipe';
import { PluralizePipe } from './pipes/pluralize.pipe';
import { MaskPipe } from './pipes/mask.pipe';

@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
FormsModule,
TruncatePipe,
TimeAgoPipe,
FileSizePipe,
PhoneNumberPipe,
FilterPipe,
SortPipe,
GroupByPipe,
HighlightPipe,
SafePipe,
DefaultPipe,
PluralizePipe,
MaskPipe
],
template: `
<div class="container">
<h1>Custom Pipes Workshop</h1>

<!-- Truncate -->
<section>
<h2>Truncate Pipe</h2>
<p>{{ longText | truncate:50:true }}</p>
</section>

<!-- Time Ago -->
<section>
<h2>Time Ago Pipe</h2>
<p>Posted: {{ pastDate | timeAgo }}</p>
</section>

<!-- File Size -->
<section>
<h2>File Size Pipe</h2>
<p>{{ 1536000 | fileSize }}</p>
<p>{{ 134217728 | fileSize:2 }}</p>
</section>

<!-- Phone Number -->
<section>
<h2>Phone Number Pipe</h2>
<p>{{ '1234567890' | phoneNumber }}</p>
</section>

<!-- Filter -->
<section>
<h2>Filter Pipe</h2>
<input [(ngModel)]="searchTerm" placeholder="Search users...">
<ul>
@for (user of users | filter:searchTerm:'name'; track user.id) {
<li>{{ user.name }} - {{ user.email }}</li>
}
</ul>
</section>

<!-- Sort -->
<section>
<h2>Sort Pipe</h2>
<select [(ngModel)]="sortOrder">
<option value="asc">Price: Low to High</option>
<option value="desc">Price: High to Low</option>
</select>
<ul>
@for (product of products | sort:'price':sortOrder; track product.id) {
<li>{{ product.name }} - ${{ product.price }}</li>
}
</ul>
</section>

<!-- Group By -->
<section>
<h2>Group By Pipe</h2>
@for (group of products | groupBy:'category'; track group.key) {
<div class="group">
<h3>{{ group.key }}</h3>
<ul>
@for (item of group.items; track item.id) {
<li>{{ item.name }}</li>
}
</ul>
</div>
}
</section>

<!-- Default Value -->
<section>
<h2>Default Value Pipe</h2>
<p>Phone: {{ user.phone | default:'Not provided' }}</p>
</section>

<!-- Pluralize -->
<section>
<h2>Pluralize Pipe</h2>
<p>{{ itemCount | pluralize:'item' }}</p>
<p>{{ peopleCount | pluralize:'person':'people' }}</p>
</section>

<!-- Mask -->
<section>
<h2>Mask Pipe</h2>
<p>Card: {{ creditCard | mask:4 }}</p>
</section>
</div>
`,
styles: [`
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}

section {
margin: 2rem 0;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
}

h2 {
color: #495057;
margin-top: 0;
}

input, select {
padding: 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
margin-bottom: 1rem;
}

.group {
margin: 1rem 0;
padding: 1rem;
background: white;
border-radius: 4px;
}
`]
})
export class AppComponent {
longText = 'This is a very long text that needs to be truncated to fit in a limited space and provide a better user experience.';
pastDate = new Date(Date.now() - 2 * 60 * 60 * 1000); // 2 hours ago

searchTerm = '';
users = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com' },
{ id: 3, name: 'Charlie Brown', email: 'charlie@example.com' }
];

sortOrder: 'asc' | 'desc' = 'asc';
products = [
{ id: 1, name: 'Laptop', price: 999, category: 'Electronics' },
{ id: 2, name: 'Mouse', price: 29, category: 'Electronics' },
{ id: 3, name: 'Shirt', price: 49, category: 'Clothing' },
{ id: 4, name: 'Jeans', price: 79, category: 'Clothing' }
];

user = { phone: null };
itemCount = 5;
peopleCount = 3;
creditCard = '4532123456789012';
}

Testing Your Pipes

Create unit tests for your pipes:

// truncate.pipe.spec.ts
import { TruncatePipe } from './truncate.pipe';

describe('TruncatePipe', () => {
let pipe: TruncatePipe;

beforeEach(() => {
pipe = new TruncatePipe();
});

it('should truncate long text', () => {
const result = pipe.transform('Hello World', 5);
expect(result).toBe('Hello...');
});

it('should not truncate short text', () => {
const result = pipe.transform('Hi', 10);
expect(result).toBe('Hi');
});

it('should respect complete words option', () => {
const result = pipe.transform('Hello World Test', 10, true);
expect(result).toBe('Hello...');
});
});

Key Concepts Practiced

✅ Creating custom pipes ✅ Pipe parameters ✅ Pure vs impure pipes ✅ DomSanitizer usage ✅ Array transformations ✅ String manipulations ✅ Date formatting ✅ Type safety with generics

Time Estimate

  • Basic pipes: 2-3 hours
  • Advanced pipes: 3-4 hours
  • Complete with tests: 5-6 hours

Next Steps

  • Add more complex transformations
  • Create chained pipe examples
  • Optimize performance
  • Add comprehensive tests