Fix: Angular Pipe Not Working — Custom Pipe Not Transforming or async Pipe Not Rendering
Quick Answer
How to fix Angular pipe issues — declaring pipes in modules, standalone pipe imports, pure vs impure pipes, async pipe with observables, pipe chaining, and custom pipe debugging.
The Problem
A custom pipe has no effect in the template:
<!-- template -->
<p>{{ message | truncate:50 }}</p>
<!-- Output: same as {{ message }} — pipe does nothing -->Or Angular throws InvalidPipeArgument:
Error: InvalidPipeArgument: 'Not a valid date' for pipe 'DatePipe'Or the async pipe shows nothing despite an observable emitting values:
<div *ngIf="data$ | async as data">
{{ data.name }}
</div>
<!-- Nothing shows even though data$ emits -->Or a custom pipe doesn’t update when the input data changes:
<li *ngFor="let item of items | filterBy:searchTerm">
<!-- List doesn't update when items array is mutated -->
</li>Why This Happens
Angular pipes have strict declaration requirements and pure/impure behavior that causes silent failures:
- Pipe not declared or imported — in module-based apps, a custom pipe must be declared in the
NgModulewhere it’s used. In standalone components, it must be in theimportsarray. Missing this causes the template compiler to silently treat the pipe as unknown. - Pure pipe doesn’t detect mutations — by default, pipes are “pure” — they only re-run when the input reference changes. Mutating an array (
push,splice) doesn’t change the reference, so a pure pipe sees no change. You need a new array reference or an impure pipe. asyncpipe requiresChangeDetectionStrategy.OnPushsetup —asyncpipe subscribes to observables and marks the view for check. Without proper change detection or when the observable completes without emitting, the view stays empty.- Wrong pipe transform signature — the
transform()method must accept the value and optional arguments in the correct order. Wrong types or missing parameters causeInvalidPipeArgument.
Fix 1: Declare or Import the Pipe Correctly
Module-based Angular:
// truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'truncate', // ← Must match the name used in template: | truncate
})
export class TruncatePipe implements PipeTransform {
transform(value: string, limit: number = 100, trail: string = '...'): string {
if (!value) return '';
return value.length > limit ? value.substring(0, limit) + trail : value;
}
}// app.module.ts — MUST declare the pipe here
@NgModule({
declarations: [
AppComponent,
TruncatePipe, // ← Pipe must be declared
],
imports: [BrowserModule],
})
export class AppModule {}// shared.module.ts — for pipes used across multiple modules
@NgModule({
declarations: [TruncatePipe, FilterPipe, SortPipe],
exports: [TruncatePipe, FilterPipe, SortPipe], // ← Export to make available to importers
})
export class SharedModule {}
// Then import SharedModule in any module that needs the pipes
@NgModule({
imports: [SharedModule],
})
export class FeatureModule {}Standalone Angular (Angular 14+):
// truncate.pipe.ts — mark as standalone
@Pipe({
name: 'truncate',
standalone: true, // ← Mark as standalone
})
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 100): string {
return value.length > limit ? value.slice(0, limit) + '...' : value;
}
}// my-component.ts — import the pipe directly
@Component({
selector: 'app-my',
standalone: true,
imports: [TruncatePipe, CommonModule], // ← Import standalone pipe
template: `<p>{{ text | truncate:50 }}</p>`,
})
export class MyComponent {}Fix 2: Write the transform() Method Correctly
The first argument to transform() is always the value before the pipe, followed by any arguments passed after the colon:
// Template usage: {{ value | myPipe:arg1:arg2 }}
@Pipe({ name: 'myPipe', standalone: true })
export class MyPipe implements PipeTransform {
// value: the input (what comes before |)
// arg1, arg2: pipe arguments (what comes after :)
transform(value: string, arg1: number, arg2 = 'default'): string {
return `${value.slice(0, arg1)} ${arg2}`;
}
}Common pipe examples:
// Currency formatting
@Pipe({ name: 'customCurrency', standalone: true })
export class CustomCurrencyPipe implements PipeTransform {
transform(value: number, currency = 'USD', decimals = 2): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: decimals,
}).format(value);
}
}
// Usage: {{ price | customCurrency:'EUR':2 }}
// Time ago pipe
@Pipe({ name: 'timeAgo', standalone: true })
export class TimeAgoPipe implements PipeTransform {
transform(value: Date | string | number): string {
const date = new Date(value);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
}Fix 3: Use Impure Pipes for Mutable Data
Pure pipes (the default) don’t update when array or object contents change:
// PROBLEM: Pure pipe doesn't detect array mutation
@Pipe({ name: 'filterBy' })
export class FilterByPipe implements PipeTransform {
transform(items: any[], term: string): any[] {
return items.filter(item => item.name.includes(term));
}
}
// In component:
addItem() {
this.items.push({ name: 'new item' }); // Mutates array — pure pipe doesn't re-run
}Fix option 1 — use pure: false (impure pipe):
@Pipe({
name: 'filterBy',
pure: false, // Runs on every change detection cycle
standalone: true,
})
export class FilterByPipe implements PipeTransform {
transform(items: any[], term: string): any[] {
if (!term) return items;
return items.filter(item =>
item.name.toLowerCase().includes(term.toLowerCase())
);
}
}Warning: Impure pipes run on every change detection cycle. For expensive operations, cache results or use memoization to avoid performance issues.
Fix option 2 — return a new array reference (better for performance):
// Instead of mutating:
this.items.push(newItem); // Mutation — pure pipe misses this
// Create a new array reference:
this.items = [...this.items, newItem]; // New reference — pure pipe re-runsFix 4: Use the async Pipe Correctly
The async pipe subscribes to Observable or Promise and unsubscribes automatically:
// Component
@Component({
selector: 'app-users',
standalone: true,
imports: [CommonModule, AsyncPipe],
template: `
<!-- Handle loading/error states -->
<ng-container *ngIf="users$ | async as users; else loading">
<li *ngFor="let user of users">{{ user.name }}</li>
</ng-container>
<ng-template #loading>Loading...</ng-template>
`,
})
export class UsersComponent {
users$ = this.userService.getUsers();
constructor(private userService: UserService) {}
}Common async pipe patterns:
<!-- Pattern 1: ngIf with as -->
<div *ngIf="data$ | async as data">
{{ data.name }}
</div>
<!-- Pattern 2: Separate loading/error/data states with RxJS -->
<!-- In component: combine state into a single observable -->
<!-- Pattern 3: ngFor with async -->
<ul>
<li *ngFor="let item of items$ | async">{{ item }}</li>
</ul>
<!-- Pattern 4: With OnPush change detection (recommended) -->// With OnPush — more efficient, async pipe triggers change detection correctly
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div *ngIf="data$ | async as data">{{ data }}</div>`,
})
export class EfficientComponent {
data$ = this.service.getData();
}Why async pipe shows nothing:
// PROBLEM 1 — Observable never emits
data$ = new Subject<string>(); // Subject with no .next() call → async shows nothing
// PROBLEM 2 — Observable errors immediately
data$ = this.http.get('/api').pipe(
// No catchError — any error kills the stream, async shows nothing
);
// FIX — handle errors
data$ = this.http.get('/api/users').pipe(
catchError(err => {
console.error(err);
return of([]); // Emit empty array on error so template shows something
})
);Fix 5: Chain Pipes and Handle Null Values
Pipes can be chained. Handle null/undefined to avoid errors:
<!-- Chaining pipes -->
{{ text | truncate:100 | uppercase }}
{{ date | date:'mediumDate' | uppercase }}
{{ price | currency:'USD' }}
<!-- Safe navigation operator + pipe -->
{{ user?.createdAt | date:'short' }}
<!-- ngIf to guard against null -->
<span *ngIf="user?.name">{{ user.name | titlecase }}</span>Handle null in custom pipes:
@Pipe({ name: 'safeHtml', standalone: true })
export class SafeHtmlPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}
transform(value: string | null | undefined): SafeHtml {
if (!value) return ''; // Guard against null/undefined
return this.sanitizer.bypassSecurityTrustHtml(value);
}
}Fix 6: Test Custom Pipes
Pipe unit tests are straightforward:
// truncate.pipe.spec.ts
import { TruncatePipe } from './truncate.pipe';
describe('TruncatePipe', () => {
let pipe: TruncatePipe;
beforeEach(() => {
pipe = new TruncatePipe();
});
it('returns the full string when shorter than limit', () => {
expect(pipe.transform('Hello', 10)).toBe('Hello');
});
it('truncates long strings', () => {
expect(pipe.transform('Hello World', 5)).toBe('Hello...');
});
it('handles null input', () => {
expect(pipe.transform(null as any, 10)).toBe('');
});
it('uses custom trail character', () => {
expect(pipe.transform('Hello World', 5, ' →')).toBe('Hello →');
});
});
// Testing in a component
describe('MyComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [MyComponent, TruncatePipe], // Import standalone pipe
});
});
it('truncates long text in the template', () => {
const fixture = TestBed.createComponent(MyComponent);
fixture.componentInstance.text = 'A very long string that should be truncated';
fixture.detectChanges();
const el = fixture.nativeElement.querySelector('p');
expect(el.textContent.length).toBeLessThan(20);
});
});Still Not Working?
Pipe name collision — if two pipes have the same name string, one overrides the other. Angular doesn’t warn about this. Check all pipe declarations for duplicate names.
date pipe requires a valid date — {{ value | date }} throws InvalidPipeArgument if value is not a Date, a number (timestamp), or an ISO 8601 string. Convert the value before piping: {{ value | date }} where value = new Date(timestamp).
Pipe in a lazy-loaded module — if the component is in a lazy-loaded module, the pipe must be declared in that module (not just the root module). In standalone mode, import the pipe directly in the component — no module boundary issues.
For related Angular issues, see Fix: Angular Change Detection Not Working and Fix: Angular Form Validation Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: RxJS Not Working — Observable Not Emitting, Memory Leak from Unsubscribed Stream, or Operator Behaving Unexpectedly
How to fix RxJS issues — subscription management, switchMap vs mergeMap vs concatMap, error handling with catchError, Subject types, cold vs hot observables, and Angular async pipe.
Fix: Angular HTTP Interceptor Not Working — Requests Not Intercepted
How to fix Angular HTTP interceptors not triggering — provideHttpClient setup, functional interceptors, order of interceptors, excluding specific URLs, and error handling.
Fix: Angular Signals Not Updating — computed() and effect() Not Triggering
How to fix Angular Signals not updating — signal mutations, computed dependency tracking, effect() cleanup, toSignal() with Observables, and migrating from zone-based change detection.
Fix: Angular Standalone Component Error — Component is Not a Known Element
How to fix Angular standalone component errors — imports array, NgModule migration, RouterModule vs RouterLink, CommonModule replacement, and mixing standalone with module-based components.