Monday, June 1, 2026

Angular Pipes

1. Introduction & Core Structure

Angular custom pipes are powerful tools designed to transform data directly within your HTML templates. They allow developers to keep presentation layouts clean by extracting heavy data-formatting logic out of templates and into reusable classes.

To build a custom pipe, you implement the PipeTransform interface and decorate the class with the @Pipe metadata token.

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'exponentialStrength',
  standalone: true // Standard configuration for modern Angular applications
})
export class ExponentialStrengthPipe implements PipeTransform {
  transform(value: number, exponent = 1): number {
    return Math.pow(value, isNaN(exponent) ? 1 : exponent);
  }
}

Template Consumption

<!-- Basic usage (Returns 2) -->
<p>{{ 2 | exponentialStrength }}</p>

<!-- Parameterized usage (Returns 8) -->
<p>{{ 2 | exponentialStrength:3 }}</p>

2. Real-World Production Examples

Truncate Text Pipe

Limits a string to a safe maximum size limit and appends a customizable fallback visual suffix.

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'truncate',
  standalone: true
})
export class TruncatePipe implements PipeTransform {
  transform(value: string, limit = 20, suffix = '...'): string {
    if (!value) return '';
    return value.length > limit ? value.substring(0, limit) + suffix : value;
  }
}

File Size Converter Pipe

Converts raw numerical bytes into highly readable string formats (KB, MB, GB) with strict data boundaries.

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'fileSize',
  standalone: true
})
export class FileSizePipe implements PipeTransform {
  private units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];

  transform(bytes: number, decimalPlaces = 2): string {
    if (bytes === 0) return '0 Bytes';
    if (!bytes || isNaN(bytes)) return '-';

    const index = Math.floor(Math.log(bytes) / Math.log(1024));
    const size = (bytes / Math.pow(1024, index)).toFixed(decimalPlaces);

    return `${size} ${this.units[index]}`;
  }
}

Initials Extractor Pipe

Pulls the first character of structural first and last names to render crisp UI user avatars.

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'initials',
  standalone: true
})
export class InitialsPipe implements PipeTransform {
  transform(fullName: string): string {
    if (!fullName) return '';

    const parts = fullName.trim().split(/\s+/);
    const firstInitial = parts[0] || '';
    const lastInitial = parts.length > 1 ? parts[parts.length - 1] : '';

    return (firstInitial[0] + (lastInitial[0] || '')).toUpperCase();
  }
}

3. Injecting Services into Custom Pipes

Pipes often need external context or configurations. Modern applications use the global inject() function to pull background services into the formatting block cleanly.

The Service Dependency

import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ConfigService {
  getLanguage(): string { return 'en'; }
}

The Injected Pipe

import { Pipe, PipeTransform, inject } from '@angular/core';
import { ConfigService } from './config.service';

@Pipe({
  name: 'age',
  standalone: true
})
export class AgePipe implements PipeTransform {
  private configService = inject(ConfigService);

  transform(birthDate: Date | string): string {
    if (!birthDate) return '';

    const today = new Date();
    const birth = new Date(birthDate);
    let age = today.getFullYear() - birth.getFullYear();
    const monthDiff = today.getMonth() - birth.getMonth();

    if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
      age--;
    }

    return this.configService.getLanguage() === 'es'
      ? `${age} aƱos`
      : `${age} years old`;
  }
}

4. Pure vs. Impure Performance Benchmarks

Angular splits pipes into two performance execution modes:

  • Pure Pipes (Default): Executes exclusively when primitive values change (String, Number) or reference pointers flip (Array, Object).
  • Impure Pipes: Executes on every single change detection pass, degrading rendering smoothness.

The Profiling Pipeline

@Pipe({ name: 'pureLog', pure: true, standalone: true })
export class PureLogPipe implements PipeTransform {
  private count = 0;
  transform(val: string): string {
    console.log(`Pure: ${++this.count}`);
    return val;
  }
}

@Pipe({ name: 'impureLog', pure: false, standalone: true })
export class ImpureLogPipe implements PipeTransform {
  private count = 0;
  transform(val: string): string {
    console.log(`Impure: ${++this.count}`);
    return val;
  }
}
The Profiling Results
  • Initial Page Painting: Both pure and impure instances run exactly once.
  • Unrelated Click Actions: Button triggers that do not update the data inputs will cause zero pure logs, while the impure pipe logs recalculate constantly, draining CPU frames.

5. Advanced Layout Patterns

Pipe Chaining

Pipes can string along sequentially. Streams parse strictly from left to right.

<!-- Input -> Capitalized -> Sliced at 16 characters -->
<p>{{ 'This is an example string' | uppercase | truncate:16 }}</p>

Streamed Asynchronous Data

<!-- Fetches from backend API stream, resolves value, handles formatting -->
<p>{{ sizeInBytes$ | async | fileSize }}</p>

TypeScript Programmatic Activation

import { Component, OnInit, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { FileSizePipe } from './file-size.pipe';

@Component({
  standalone: true,
  providers: [CurrencyPipe, FileSizePipe],
  template: `<p>{{ report }}</p>`
})
export class ReportComponent implements OnInit {
  private currency = inject(CurrencyPipe);
  private fileSize = inject(FileSizePipe);
  report = '';

  ngOnInit() {
    const price = this.currency.transform(29.99, 'USD');
    const size = this.fileSize.transform(1048576);
    this.report = `Asset cost: ${price} | Download payload: ${size}`;
  }
}

6. Guards & Resolvers Core Integration

Pipes can operate directly inside navigation interceptors to filter data models before route assembly completes.

The Guard

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { map, take } from 'rxjs';
import { DataService } from './data.service';
import { FileSizePipe } from './file-size.pipe';

export const structuralGuard: CanActivateFn = (route) => {
  const router = inject(Router);
  const fileSizePipe = inject(FileSizePipe);

  return inject(DataService).getFileMetadata(route.paramMap.get('id')!).pipe(
    take(1),
    map(file => {
      const sizeStr = fileSizePipe.transform(file.sizeBytes);
      return (sizeStr.includes('GB') && parseFloat(sizeStr) > 5)
        ? router.createUrlTree(['/limit-exceeded'])
        : true;
    })
  );
};

The Resolver

import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { map } from 'rxjs';
import { UserService } from './user.service';
import { InitialsPipe } from './initials.pipe';

export const profileResolver: ResolveFn<any> = (route) => {
  const initialsPipe = inject(InitialsPipe);

  return inject(UserService).getUserById(route.paramMap.get('id')!).pipe(
    map(user => ({
      ...user,
      avatarInitials: initialsPipe.transform(user.name)
    }))
  );
};

7. Isolated Testing & Mocking (Jasmine)

Because pipes are clean classes, regular implementations do not need heavy initialization overhead. When dependencies are present, leverage TestBed.inject.

import { TestBed } from '@angular/core/testing';
import { AgePipe } from './age.pipe';
import { ConfigService } from './config.service';

describe('AgePipe with Mock Service', () => {
  let pipe: AgePipe;
  let mockService: jasmine.SpyObj<ConfigService>;

  beforeEach(() => {
    const spy = jasmine.createSpyObj('ConfigService', ['getLanguage']);

    TestBed.configureTestingModule({
      providers: [AgePipe, { provide: ConfigService, useValue: spy }]
    });

    pipe = TestBed.inject(AgePipe);
    mockService = TestBed.inject(ConfigService) as jasmine.SpyObj<ConfigService>;

    jasmine.clock().install();
    jasmine.clock().mockDate(new Date('2026-06-01'));
  });

  afterEach(() => jasmine.clock().uninstall());

  it('should parse output text inside English localization targets', () => {
    mockService.getLanguage.and.returnValue('en');
    expect(pipe.transform('1990-06-01')).toBe('36 years old');
  });
});

Truncate Text Pipe Tests

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

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

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

  it('should create an instance', () => {
    expect(pipe).toBeTruthy();
  });

  it('should truncate text at default limit (20) and add "..."', () => {
    const text = 'This is a very long string for testing';
    expect(pipe.transform(text)).toBe('This is a very long ...');
  });

  it('should honor a custom limit parameter', () => {
    const text = 'Hello World';
    expect(pipe.transform(text, 5)).toBe('Hello...');
  });

  it('should append a custom suffix parameter', () => {
    const text = 'Hello World';
    expect(pipe.transform(text, 5, '!!!')).toBe('Hello!!!');
  });

  it('should return the original string if it is shorter than the limit', () => {
    const text = 'Short';
    expect(pipe.transform(text, 10)).toBe('Short');
  });

  it('should handle empty or null values gracefully', () => {
    expect(pipe.transform('')).toBe('');
    expect(pipe.transform(null as any)).toBe('');
  });
});

2. File Size Converter Pipe Tests

import { FileSizePipe } from './file-size.pipe';

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

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

  it('should convert bytes to KB correctly', () => {
    expect(pipe.transform(1024)).toBe('1.00 KB');
  });

  it('should convert bytes to MB correctly', () => {
    expect(pipe.transform(1048576)).toBe('1.00 MB');
  });

  it('should respect custom decimal places parameter', () => {
    expect(pipe.transform(1500000, 3)).toBe('1.431 MB');
  });

  it('should return "0 Bytes" when input is 0', () => {
    expect(pipe.transform(0)).toBe('0 Bytes');
  });

  it('should return "-" for null or invalid numbers', () => {
    expect(pipe.transform(null as any)).toBe('-');
    expect(pipe.transform(NaN)).toBe('-');
  });
});

3. Initials Extractor Pipe Tests

import { InitialsPipe } from './initials.pipe';

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

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

  it('should extract first and last initials and make them uppercase', () => {
    expect(pipe.transform('john doe')).toBe('JD');
  });

  it('should handle single names by returning one initial', () => {
    expect(pipe.transform('Alex')).toBe('A');
  });

  it('should handle middle names by extracting only the first and last initials', () => {
    expect(pipe.transform('John Fitzgerald Kennedy')).toBe('JK');
  });

  it('should handle extra whitespace around names', () => {
    expect(pipe.transform('   Jane   Smith   ')).toBe('JS');
  });

  it('should return an empty string for empty inputs', () => {
    expect(pipe.transform('')).toBe('');
    expect(pipe.transform(null as any)).toBe('');
  });
});

8. Transitioning to Modern Angular Signals

Angular Signals fundamentally update data strategy mechanics. With Signals, custom HTML pipes are completely obsolete.

Instead of routing values through pipes inside templates, use standard computed() signals. They cache calculations efficiently and update targeted elements instantly without running expensive change detection passes.

The Evolution Comparison

❌ The Legacy Template Approach
<div>{{ profileName | uppercase | truncate:10 }}</div>
The Modern Reactive Signals Approach
import { Component, signal, computed } from '@angular/core';

@Component({
  standalone: true,
  template: `<div>{{ managedProfileName() }}</div>` // Evaluates instantly via direct signal node linkage
})
export class ModernProfileComponent {
  // Writable input data stream
  profileName = signal('Jonathan Abernathy');

  // Unified computed transformation state matrix (Fully memoized out-of-the-box)
  managedProfileName = computed(() => {
    const current = this.profileName().toUpperCase();
    return current.length > 10 ? current.substring(0, 10) + '...' : current;
  });
}

No comments:

Post a Comment

React Custom Hooks

React custom hooks are reusable JavaScript functions that encapsulate stateful logic, allowing you to share functionality a...