| |

Angular 12 🅰️ Custom Pipes

Built-in pipes cover the common cases — dates, numbers, currency, case. But every app has formatting needs the built-ins don’t handle: turning a status code into a label, truncating text with an ellipsis, highlighting a search term, converting bytes to KB/MB/GB, filtering a list, sorting an array, or mapping an enum value to something user-friendly. That’s what custom pipes are for. A custom pipe is a class with a @Pipe decorator and a transform method — the same shape as the built-ins, just written by you.

Key point: A custom pipe is a pure function wrapped in a class. It receives a value (plus optional arguments), returns a new value, and Angular calls it in the template. Mark it pure: true (the default) unless it needs to run on every change detection cycle — impure pipes are expensive and should be rare. Custom pipes are for display transformation, not business logic.


Anatomy of a custom pipe

A custom pipe has three parts: the @Pipe decorator, the class, and the transform method.

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

@Pipe({
  name: 'greet',
  standalone: true
})
export class GreetPipe implements PipeTransform {
  transform(value: string): string {
    return `Hello, ${value}!`;
  }
}

What each part does:

  • @Pipe({ name: 'greet' }) — registers the pipe under the name greet
  • implements PipeTransform — implements the interface Angular expects
  • transform(value, ...args) — the method Angular calls with the piped value

Use it in a template:

{{ 'Alice' | greet }}   <!-- Hello, Alice! -->

The name is what appears after the | in templates. It must be a valid identifier — no dashes, no spaces.

standalone: true makes the pipe importable directly by standalone components. Without it, the pipe must be declared in an NgModule.

The transform method:

transform(value: T, ...args: unknown[]): U
  • value — the value to the left of the |
  • args — any arguments after the pipe name, separated by colons
  • Return type — whatever the pipe produces

Angular calls transform every time the input changes (for pure pipes) or on every change detection cycle (for impure pipes).

Why the class structure: Angular needs a class to register the pipe with the DI system and the template compiler. The @Pipe decorator provides metadata; the class holds the logic. It’s the same pattern as components and directives — decorator plus class.


Passing arguments to a pipe

Pipes accept arguments after the name, separated by colons.

@Pipe({ name: 'exclaim', standalone: true })
export class ExclaimPipe implements PipeTransform {
  transform(value: string, count: number = 1): string {
    return value + '!'.repeat(count);
  }
}

Use it:

{{ 'Hello' | exclaim }}       <!-- Hello! -->
{{ 'Hello' | exclaim:3 }}     <!-- Hello!!! -->

Multiple arguments:

@Pipe({ name: 'repeat', standalone: true })
export class RepeatPipe implements PipeTransform {
  transform(value: string, times: number, separator: string = ' '): string {
    return Array(times).fill(value).join(separator);
  }
}
{{ 'ha' | repeat:3 }}          <!-- ha ha ha -->
{{ 'ha' | repeat:3:'-' }}      <!-- ha-ha-ha -->

Arguments can be dynamic:

{{ text | slice:0:maxLength }}

maxLength is a component property — when it changes, the pipe re-runs.

Argument types: They can be numbers, strings, booleans, objects, or even other pipes’ results. TypeScript checks them via the transform signature.

Common pattern — format string:

@Pipe({ name: 'fileSize', standalone: true })
export class FileSizePipe implements PipeTransform {
  transform(bytes: number, decimals: number = 2): string {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
  }
}
{{ file.size | fileSize }}       <!-- 1.50 MB -->
{{ file.size | fileSize:0 }}     <!-- 2 MB -->
{{ file.size | fileSize:3 }}     <!-- 1.504 MB -->

Why arguments matter: A pipe without arguments does one thing. A pipe with arguments is reusable across different formatting needs. fileSize:0 for compact display, fileSize:2 for detail — same pipe, two behaviors. That flexibility is what makes custom pipes worth writing.


A practical example — status label

A common need: turn a status code into a user-facing label.

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

type Status = 'idle' | 'loading' | 'ready' | 'error';

@Pipe({
  name: 'statusLabel',
  standalone: true
})
export class StatusLabelPipe implements PipeTransform {
  private readonly labels: Record<Status, string> = {
    idle: 'Waiting',
    loading: 'Loading...',
    ready: 'Ready',
    error: 'Something went wrong'
  };

  transform(value: Status): string {
    return this.labels[value] ?? 'Unknown';
  }
}

Use it:

<p>Status: {{ status | statusLabel }}</p>

When status is 'ready', the template renders Status: Ready.

Why this is useful: The label mapping lives in one place. Change the wording once, and every template updates. The component class stays focused on state; the pipe handles the display text.

Alternatives to consider:

  • @switch block — inline in the template, but repeated in every use
  • A component method — works, but clutters the class
  • A computed signal — modern, but ties the label to the component

The pipe is the right tool when the mapping is reusable across components.

Why pipes beat methods for this: A method would be {{ getStatusLabel(status) }}. It works, but it runs on every CD cycle and can’t be reused elsewhere without duplication. A pipe is {{ status | statusLabel }} — declarative, reusable, and pure. Same idea, better shape.


Pure vs impure pipes

By default, pipes are pure — Angular runs them only when the input changes by reference.

@Pipe({ name: 'x', standalone: true })
// pure: true is the default

Pure pipes are efficient. Angular caches the result and skips re-running the pipe if the input hasn’t changed.

Impure pipes run on every change detection cycle — even if the input hasn’t changed.

@Pipe({ name: 'x', standalone: true, pure: false })

When to use impure:

  • The input is a mutable object or array whose contents change without a new reference
  • The pipe depends on external state (time, a global, a signal)
  • The pipe must re-evaluate on every CD cycle

When NOT to use impure:

  • Almost always. Impure pipes are a performance trap.
  • If you need reactivity to a signal, use a computed instead
  • If you need reactivity to an observable, use async + @if with as

The mutable array problem:

@Pipe({ name: 'firstThree' })
export class FirstThreePipe implements PipeTransform {
  transform(items: string[]): string[] {
    return items.slice(0, 3);
  }
}

Pure version works when the array reference changes. If you push to the array without creating a new reference, the pipe won’t re-run. Making it impure fixes that — but at the cost of running on every CD cycle. A better fix: create a new array when you modify it.

Why pure is the default: Angular’s whole change detection model is based on reference equality. Pure pipes fit that model. Impure pipes break it and slow down the app. The built-ins are mostly pure for the same reason.

Why pure pipes matter for performance: A pure pipe over a large array runs once and caches. An impure pipe over the same array runs on every keystroke, mouse move, and timer tick. On a busy app, that’s thousands of unnecessary computations per second. Purity is the difference between a smooth app and a stuttering one.


Pipes and standalone components

In standalone components, import the pipe directly.

import { Component } from '@angular/core';
import { FileSizePipe } from './file-size.pipe';

@Component({
  selector: 'app-file',
  standalone: true,
  imports: [FileSizePipe],
  template: `<p>{{ size | fileSize }}</p>`
})
export class FileComponent {
  size = 1_500_000;
}

Why import each pipe: Standalone components import only what they use. CommonModule pulls in every built-in pipe and directive — unnecessary bloat if you only use one or two.

For module-based components:

@NgModule({
  declarations: [StatusLabelPipe],
  exports: [StatusLabelPipe]
})
export class SharedPipesModule {}

Then import SharedPipesModule wherever the pipe is used.

Common patterns:

  • Feature pipes — colocated with the feature that uses them
  • Shared pipes — in a shared/ folder with a SharedPipesModule or exported individually
  • Pipe-per-file — one file per pipe, easy to find and test

Why standalone imports are cleaner: With modules, every pipe lived in a module, and you imported the module to use the pipe. With standalone, you import exactly the pipes you use. The dependency is explicit — you can see what a component relies on.


Testing custom pipes

A pipe is a pure function behind a class — easy to test.

import { StatusLabelPipe } from './status-label.pipe';

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

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

  it('maps idle to Waiting', () => {
    expect(pipe.transform('idle')).toBe('Waiting');
  });

  it('maps ready to Ready', () => {
    expect(pipe.transform('ready')).toBe('Ready');
  });

  it('returns Unknown for unexpected values', () => {
    expect(pipe.transform('nonsense' as never)).toBe('Unknown');
  });
});

No TestBed needed — instantiate the class and call transform. That’s the payoff of keeping pipes focused on pure transformation.

Why this is easy: A pipe with no dependencies is a pure function. You don’t need to render a component or set up a test module. Just new it up and assert on the output. Fast, focused tests.

Why pipes are naturally testable: Their only input is the value and arguments; their only output is the transformed value. No DOM, no async, no side effects. That makes them the easiest thing in Angular to test — and a good sign you’ve designed them right.


Pipe composition and chaining

Pipes chain naturally, and custom pipes work alongside built-ins.

{{ name | titlecase | slice:0:10 | uppercase }}

Each pipe receives the previous pipe’s output.

Custom pipe with built-in:

{{ fileSize | fileSize:2 | uppercase }}

When to chain vs write a bigger pipe: If the same chain appears in multiple places, wrap it in a single pipe. If it appears once, chaining is fine.

Chaining with arguments:

{{ 'hello world' | slice:0:5 | uppercase }}   <!-- HELLO -->

Pipes in property binding:

<img [alt]="description | slice:0:50">
<a [title]="name | titlecase">Link</a>

Why chaining is powerful: Each pipe is a small transformation. Chaining composes them into a pipeline. That’s the Unix philosophy applied to templates — small tools, composed. It’s more readable than one big custom pipe doing five things.


Real-world pipe patterns

Common custom pipe use cases and how to implement them.

Truncate with ellipsis:

@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
  transform(value: string, max: number = 20, ellipsis: string = '...'): string {
    return value.length > max ? value.slice(0, max) + ellipsis : value;
  }
}

Pluralize:

@Pipe({ name: 'plural', standalone: true })
export class PluralPipe implements PipeTransform {
  transform(count: number, singular: string, plural?: string): string {
    return count === 1 ? singular : (plural ?? singular + 's');
  }
}
{{ count }} {{ count | plural:'item' }}      <!-- 1 item / 2 items -->
{{ count }} {{ count | plural:'box':'boxes' }}  <!-- 1 box / 2 boxes -->

Relative time:

@Pipe({ name: 'timeAgo', standalone: true })
export class TimeAgoPipe implements PipeTransform {
  transform(value: Date | string): string {
    const date = typeof value === 'string' ? new Date(value) : value;
    const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
    if (seconds < 60) return 'just now';
    const minutes = Math.floor(seconds / 60);
    if (minutes < 60) return `${minutes}m ago`;
    const hours = Math.floor(minutes / 60);
    if (hours < 24) return `${hours}h ago`;
    const days = Math.floor(hours / 24);
    return `${days}d ago`;
  }
}

Filtering a list (careful — see pitfalls):

@Pipe({ name: 'filterBy', standalone: true })
export class FilterByPipe implements PipeTransform {
  transform<T>(items: T[], key: keyof T, value: unknown): T[] {
    return items.filter(item => item[key] === value);
  }
}

Highlight a search term:

@Pipe({ name: 'highlight', standalone: true })
export class HighlightPipe implements PipeTransform {
  transform(value: string, term: string): string {
    if (!term) return value;
    const re = new RegExp(`(${term})`, 'gi');
    return value.replace(re, '<mark>$1</mark>');
  }
}

Use with [innerHTML] — be careful with user input.

Safe URL:

@Pipe({ name: 'safeUrl', standalone: true })
export class SafeUrlPipe implements PipeTransform {
  private sanitizer = inject(DomSanitizer);

  transform(url: string): SafeResourceUrl {
    return this.sanitizer.bypassSecurityTrustResourceUrl(url);
  }
}

Why so many patterns: Pipes are for display transformation, and display transformation has many shapes. Truncation, pluralization, time formatting, filtering — each is a small, reusable concern. Pipes are the right size for these.

Why pipes are not for filtering (usually): Filtering in a pipe requires an impure pipe (because arrays change by content, not reference), and impure pipes run on every CD cycle. That’s a performance problem. Modern Angular prefers filtering in the component (via computed signals or class getters) and rendering the result. Use filter pipes only when the list is small and the filter is stable.


A full example

A dashboard component using three custom 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 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
  }
}
// status-label.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

type Status = 'idle' | 'loading' | 'ready' | 'error';

@Pipe({ name: 'statusLabel', standalone: true })
export class StatusLabelPipe implements PipeTransform {
  private readonly labels: Record<Status, string> = {
    idle: 'Waiting',
    loading: 'Loading...',
    ready: 'Ready',
    error: 'Failed'
  };

  transform(value: Status): string {
    return this.labels[value] ?? 'Unknown';
  }
}
// time-ago.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'timeAgo', standalone: true })
export class TimeAgoPipe implements PipeTransform {
  transform(value: Date): string {
    const seconds = Math.floor((Date.now() - value.getTime()) / 1000);
    if (seconds < 60) return 'just now';
    const minutes = Math.floor(seconds / 60);
    if (minutes < 60) return `${minutes}m ago`;
    const hours = Math.floor(minutes / 60);
    if (hours < 24) return `${hours}h ago`;
    return `${Math.floor(hours / 24)}d ago`;
  }
}
// dashboard.component.ts
import { Component } from '@angular/core';
import { FileSizePipe } from './file-size.pipe';
import { StatusLabelPipe } from './status-label.pipe';
import { TimeAgoPipe } from './time-ago.pipe';

interface Document {
  name: string;
  size: number;
  status: 'idle' | 'loading' | 'ready' | 'error';
  updatedAt: Date;
}

@Component({
  selector: 'app-dashboard',
  standalone: true,
  imports: [FileSizePipe, StatusLabelPipe, TimeAgoPipe],
  template: `
    <h1>Documents</h1>
    @for (doc of documents; track doc.name) {
      <div class="doc">
        <strong>{{ doc.name }}</strong>
        <span>{{ doc.size | fileSize }}</span>
        <span>{{ doc.status | statusLabel }}</span>
        <span>{{ doc.updatedAt | timeAgo }}</span>
      </div>
    }
  `
})
export class DashboardComponent {
  documents: Document[] = [
    { name: 'report.pdf', size: 1_500_000, status: 'ready', updatedAt: new Date(Date.now() - 300_000) },
    { name: 'image.png', size: 250_000, status: 'loading', updatedAt: new Date() },
    { name: 'archive.zip', size: 45_000_000, status: 'error', updatedAt: new Date(Date.now() - 86_400_000) }
  ];
}

Each pipe does one job: fileSize for readable sizes, statusLabel for user-facing status text, timeAgo for relative timestamps. The component class holds data; the pipes format it.

Why this shape works: Three small, focused pipes replace three helpers, three methods, or three inline transformations. Each is independently testable, reusable in other components, and easy to change. That’s the value of custom pipes — one concern, one class, one place to change.


Complete Example Session

# ============================================
# PART 1: GENERATE A PIPE
# ============================================

ng generate pipe file-size
# [ CREATE src/app/file-size.pipe.ts ]
# [ CREATE src/app/file-size.pipe.spec.ts ]

# ============================================
# PART 2: WRITE THE PIPE
# ============================================

cat > src/app/file-size.pipe.ts << 'EOF'
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 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
  }
}
EOF

# ============================================
# PART 3: WRITE A SECOND PIPE
# ============================================

cat > src/app/status-label.pipe.ts << 'EOF'
import { Pipe, PipeTransform } from '@angular/core';

type Status = 'idle' | 'loading' | 'ready' | 'error';

@Pipe({
  name: 'statusLabel',
  standalone: true
})
export class StatusLabelPipe implements PipeTransform {
  private readonly labels: Record<Status, string> = {
    idle: 'Waiting',
    loading: 'Loading...',
    ready: 'Ready',
    error: 'Failed'
  };

  transform(value: Status): string {
    return this.labels[value] ?? 'Unknown';
  }
}
EOF

# ============================================
# PART 4: USE THEM IN A COMPONENT
# ============================================

cat > src/app/demo/demo.component.ts << 'EOF'
import { Component } from '@angular/core';
import { FileSizePipe } from '../file-size.pipe';
import { StatusLabelPipe } from '../status-label.pipe';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [FileSizePipe, StatusLabelPipe],
  template: `
    <h2>Pipes Demo</h2>
    @for (f of files; track f.name) {
      <p>
        {{ f.name }} — {{ f.size | fileSize }} — {{ f.status | statusLabel }}
      </p>
    }

    <p>Zero: {{ 0 | fileSize }}</p>
    <p>Small: {{ 500 | fileSize:0 }}</p>
    <p>Large: {{ 5_368_709_120 | fileSize:2 }}</p>
  `
})
export class DemoComponent {
  files = [
    { name: 'a.pdf', size: 1_500_000, status: 'ready' as const },
    { name: 'b.png', size: 250_000, status: 'loading' as const },
    { name: 'c.zip', size: 45_000_000, status: 'error' as const }
  ];
}
EOF

# ============================================
# PART 5: TEST THE PIPE
# ============================================

cat > src/app/file-size.pipe.spec.ts << 'EOF'
import { FileSizePipe } from './file-size.pipe';

describe('FileSizePipe', () => {
  const pipe = new FileSizePipe();

  it('returns 0 B for zero', () => {
    expect(pipe.transform(0)).toBe('0 B');
  });

  it('formats bytes', () => {
    expect(pipe.transform(500)).toBe('500 B');
  });

  it('formats KB', () => {
    expect(pipe.transform(2048)).toBe('2 KB');
  });

  it('formats MB', () => {
    expect(pipe.transform(1_500_000)).toBe('1.43 MB');
  });

  it('respects decimal places', () => {
    expect(pipe.transform(1_500_000, 0)).toBe('1 MB');
  });
});
EOF

# ============================================
# PART 6: RUN TESTS AND SERVE
# ============================================

ng test --watch=false
# [ Chrome Headless: Executed 5 of 5 SUCCESS ]

ng serve
# [ Local:   http://localhost:4200/ ]

Two pipes, a component that uses them, and a test file — the full workflow for custom pipes.

Why this pattern is standard: Generate with the CLI, fill in the transform method, import where used, test in isolation. That’s how every pipe you write will look. The CLI handles the boilerplate; you handle the logic.


Quick Reference

Pipe Anatomy

PartPurpose
@Pipe({ name })Register the pipe
implements PipeTransformRequired interface
transform(value, ...args)Transform logic
standalone: trueImportable directly

Pipe Metadata

OptionDefaultPurpose
nameTemplate identifier
puretrueRun only on input change
standalonefalseNo module required

Syntax

FormMeaning
{{ v | pipe }}Basic
{{ v | pipe:arg }}With argument
{{ v | pipe:a:b }}Multiple arguments
{{ v | p1 | p2 }}Chained

Purity

TypeRuns
PureWhen input changes
ImpureEvery CD cycle

Common Custom Pipes

PipePurpose
truncateTruncate with ellipsis
fileSizeBytes → KB/MB/GB
statusLabelEnum → label
pluralPluralize by count
timeAgoRelative time
highlightHighlight search term
safeUrlBypass URL sanitization
initialsName → initials
maskMask sensitive data
sortBySort array

When to Write a Pipe

SituationPipe?
Reusable display transformation
Same formatting in multiple templates
Pure transformation
Filtering a large list⚠️ prefer computed
Async work❌ use async pipe
Side effects
State-dependent❌ use signals/computed

Standalone Imports

NeedImport
Single pipeimports: [MyPipe]
All built-ins (module)CommonModule

Testing

StepCode
Instantiatenew MyPipe()
Call transformpipe.transform(v, args)
Assertexpect(result).toBe(...)
No TestBedFor pure pipes

CLI

CommandPurpose
ng g pipe NAMEGenerate pipe
ng g p NAMEShorthand
ng g p NAME --skip-testsNo spec
ng g p NAME --flatNo folder

Best Practices

Do This:

// Name pipes descriptively
@Pipe({ name: 'fileSize', standalone: true })              // ✅

// Keep pipes pure by default
@Pipe({ name: 'x', standalone: true })                     // ✅ pure: true

// Type the transform signature
transform(value: string, max: number = 20): string { }     // ✅

// Provide sensible defaults for arguments
transform(bytes: number, decimals: number = 2): string { } // ✅

// Import pipes individually in standalone components
imports: [FileSizePipe, StatusLabelPipe]                   // ✅

// Test pipes in isolation
const pipe = new FileSizePipe();                           // ✅

// Use pipes for display, not logic
{{ price | currency:'EUR' }}                               // ✅

// Chain small pipes instead of writing big ones
{{ name | titlecase | slice:0:10 }}                        // ✅

Don’t Do This:

// Don't make pipes impure casually
@Pipe({ name: 'x', pure: false })                          // ⚠️  performance trap

// Don't do heavy computation in a pipe
transform(items: Item[]): Item[] {
  return items.sort(expensiveCompare);                     // ❌
}

// Don't mutate the input value
transform(items: Item[]): Item[] {
  items.push(...);  // ❌ mutation                              // ❌
  return items;
}

// Don't filter large lists in pipes
@Pipe({ name: 'filter', pure: false })                     // ⚠️  use computed

// Don't forget to import the pipe
template: `{{ x | myPipe }}`  // ❌ without imports            // ❌

// Don't put business logic in pipes
transform(user: User): string {
  return this.api.getStatus(user);                         // ❌ side effect
}

// Don't over-chain — extract to a named pipe
{{ x | a | b | c | d | e }}                                // ⚠️  maybe one pipe

// Don't name a pipe with dashes or spaces
@Pipe({ name: 'my-pipe' })                                 // ❌ invalid

Common Pitfalls

PitfallProblemSolution
Forgetting standalone: trueCan’t import directlyAdd it or declare in module
Impure pipe overusePerformanceKeep pure, use signals for reactivity
Mutating inputSide effectsReturn new values
Heavy computationRuns every CDMove to class or computed
Filtering in a pipeNeeds impureUse computed or class getter
Missing implements PipeTransformWorks but no type checkImplement it
Wrong argument orderUnexpected outputDocument the signature
No default valuesErrors when omittedDefault optional args
Not testingBugs slip throughUnit test each pipe

Real-World Examples

1. Basic pipe

@Pipe({ name: 'greet', standalone: true })
export class GreetPipe implements PipeTransform {
  transform(v: string): string { return `Hello, ${v}`; }
}

2. Pipe with argument

transform(v: string, count: number): string { return v.repeat(count); }

3. Pipe with default

transform(v: string, count: number = 2): string { return v.repeat(count); }

4. Truncate

transform(v: string, max = 20, ellipsis = '...'): string {
  return v.length > max ? v.slice(0, max) + ellipsis : v;
}

5. File size

transform(bytes: number, decimals = 2): string { /* ... */ }

6. Status label

transform(v: 'idle' | 'ready'): string { /* map */ }

7. Pluralize

transform(count: number, singular: string, plural?: string): string {
  return count === 1 ? singular : (plural ?? singular + 's');
}

8. Relative time

transform(v: Date): string { /* '5m ago' */ }

9. Highlight

transform(v: string, term: string): string {
  return v.replace(new RegExp(term, 'gi'), '<mark>$&</mark>');
}

10. Safe URL

const sanitizer = inject(DomSanitizer);
transform(url: string) { return this.sanitizer.bypassSecurityTrustUrl(url); }

11. Initials

transform(name: string): string {
  return name.split(' ').map(w => w[0]).join('').toUpperCase();
}

12. Mask

transform(v: string, show = 4): string {
  return '*'.repeat(v.length - show) + v.slice(-show);
}

13. Sort

transform<T>(items: T[], key: keyof T): T[] {
  return [...items].sort((a, b) => (a[key] > b[key] ? 1 : -1));
}

14. Reverse

transform<T>(items: T[]): T[] { return [...items].reverse(); }

15. Unique

transform<T>(items: T[]): T[] { return [...new Set(items)]; }

16. Chained custom + built-in

{{ size | fileSize | uppercase }}

17. Pipe in a block

@if (status | statusLabel; as label) { <p>{{ label }}</p> }

18. Test a pipe

const pipe = new FileSizePipe();
expect(pipe.transform(1024)).toBe('1 KB');

19. Generate a pipe

ng generate pipe file-size

20. Import a pipe

imports: [FileSizePipe]

Visual: Pipe Anatomy

┌──────────────────────────────────────────────┐
│  @Pipe({ name: 'fileSize' })                 │
│         │                                    │
│         │  registers as `fileSize`           │
│         ▼                                    │
│  export class FileSizePipe                   │
│         implements PipeTransform             │
│         │                                    │
│         ▼                                    │
│  transform(bytes, decimals)                  │
│         │                                    │
│         ▼                                    │
│  return '1.5 MB'                             │
│                                              │
└──────────────────────────────────────────────┘

Visual: Template to Pipe Flow

┌──────────────────────────────────────────────┐
│  Template                                    │
│                                              │
│  {{ file.size | fileSize:2 }}                │
│       │              │                       │
│       │              └── argument            │
│       │                                      │
│       └── input value                        │
│                                              │
└──────────────────────────────────────────────┘
                  │
                  ▼
┌──────────────────────────────────────────────┐
│  FileSizePipe.transform(1500000, 2)          │
│       │                                      │
│       ▼                                      │
│  '1.43 MB'                                   │
│                                              │
└──────────────────────────────────────────────┘

Visual: Pure vs Impure

┌──────────────────────────────────────────────┐
│  Pure (default)                              │
│                                              │
│  Input ──► change? ──► yes ──► transform     │
│                    └► no  ──► cached         │
│                                              │
│  Runs rarely — fast                          │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Impure                                      │
│                                              │
│  CD cycle ──► transform                      │
│                                              │
│  Runs every CD — slow                        │
│                                              │
└──────────────────────────────────────────────┘

Visual: Standalone Import

┌──────────────────────────────────────────────┐
│  @Component({                                │
│    standalone: true,                         │
│    imports: [                                │
│      FileSizePipe,                           │
│      StatusLabelPipe,                        │
│      DatePipe                                │
│    ],                                        │
│    template: `...`                           │
│  })                                          │
│                                              │
│  Only what the template uses                 │
│                                              │
└──────────────────────────────────────────────┘

Visual: Chaining

┌──────────────────────────────────────────────┐
│  {{ name | titlecase | slice:0:10 }}         │
│                                              │
│  'alice johnson'                             │
│       │                                      │
│       ▼                                      │
│  titlecase  → 'Alice Johnson'                │
│       │                                      │
│       ▼                                      │
│  slice:0:10 → 'Alice John'                   │
│                                              │
└──────────────────────────────────────────────┘

Visual: Testing

┌──────────────────────────────────────────────┐
│  const pipe = new FileSizePipe();            │
│                                              │
│  expect(pipe.transform(1024)).toBe('1 KB');  │
│  expect(pipe.transform(0)).toBe('0 B');      │
│  expect(pipe.transform(1e6, 0)).toBe('1 MB') │
│                                              │
│  • No TestBed                               │
│  • No DOM                                   │
│  • Pure function                            │
│  • Fast to run                              │
│                                              │
└──────────────────────────────────────────────┘

Visual: Custom Pipe Decision

┌──────────────────────────────────────────────┐
│  Is this a display transformation?           │
│       │                                      │
│       ├── No ──► don't use a pipe            │
│       │                                      │
│       └── Yes ──► Reusable across templates? │
│                     │                        │
│                     ├── Yes ──► pipe         │
│                     │                        │
│                     └── No ──► inline in     │
│                                 template or  │
│                                 class method │
│                                              │
└──────────────────────────────────────────────┘

Visual: Common Pipe Categories

┌──────────────────────────────────────────────┐
│  String          →  truncate, highlight,     │
│                     initials, mask           │
│                                              │
│  Number          →  fileSize, ordinal,       │
│                     format                   │
│                                              │
│  Date/time       →  timeAgo, customDate      │
│                                              │
│  Enum/labels     →  statusLabel, roleLabel   │
│                                              │
│  List            →  sortBy, unique, reverse  │
│                     (small lists only)       │
│                                              │
│  URL             →  safeUrl                  │
│                                              │
└──────────────────────────────────────────────┘

Summary

ConceptMeaning
Custom pipeClass with @Pipe + transform
@Pipe({ name })Registers in templates
transform(v, ...args)Transformation logic
standalone: trueImportable directly
PureRuns when input changes
ImpureRuns every CD cycle
ArgumentsAfter pipe name with :
Chaining{{ v | p1 | p2 }}
TestingInstantiate and call transform

Key takeaways:

  • A custom pipe is a class with @Pipe and a transform method
  • Register with @Pipe({ name: 'x' }); use as {{ v | x }}
  • Pass arguments after the name with colons — {{ v | x:arg1:arg2 }}
  • Pure (default) pipes run only when input changes; impure run every CD cycle
  • Keep pipes pure and cheap — heavy work belongs elsewhere
  • Mark standalone: true to import directly in standalone components
  • Import each pipe individually — don’t pull in CommonModule for one
  • Test pipes in isolation — no TestBed needed for pure pipes
  • Chain pipes for composition — small transformations that compose well
  • Avoid filtering in pipes — use computed signals or class getters
  • Common patterns — truncate, fileSize, statusLabel, plural, timeAgo, safeUrl
  • Use the CLIng generate pipe NAME

Remember: Custom pipes are for display transformation — the same job as built-in pipes, just for cases the built-ins don’t cover. Keep them pure, keep them focused, and test them in isolation. They’re the right tool when a formatting need repeats across templates and the logic is a pure function of the input. When it isn’t — filtering, async work, state-driven transformations — reach for a different tool. A pipe should do one thing, do it purely, and return a new value.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!