Skip to content

Fix: Angular Pipe Not Working — Custom Pipe Not Transforming or async Pipe Not Rendering

FixDevs ·

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 NgModule where it’s used. In standalone components, it must be in the imports array. 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.
  • async pipe requires ChangeDetectionStrategy.OnPush setupasync pipe 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 cause InvalidPipeArgument.

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-runs

Fix 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles