Eingebaute Pipes wie DatePipe, CurrencyPipe oder AsyncPipe decken einen Großteil der Formatierungs-Aufgaben ab — aber irgendwann brauchst du etwas Eigenes: einen Truncate, einen Filter, einen Markdown-Renderer. Eigene Pipes sind in Angular 21 ein Zweizeiler mit @Pipe und PipeTransform — und genau deshalb ist die wichtigere Frage nicht „wie baue ich eine?”, sondern „pure oder impure, und brauche ich überhaupt eine, oder reicht ein computed()?”. Dieser Artikel beantwortet beides.

Was ist eine Pipe?

Eine Pipe ist eine Klasse mit genau einer Aufgabe: einen Wert nehmen, ihn transformieren, einen neuen Wert zurückgeben. Im Template rufst du sie über die |-Syntax auf: {{ value | myPipe }}. Konzeptionell ist eine Pipe nichts anderes als eine Funktion (value, ...args) => result — Angular wickelt sie nur in eine Klasse, damit sie injectable ist und in der Template-Engine effizient gecached werden kann.

Du könntest dieselbe Logik auch als Helper-Methode in der Komponente unterbringen ({{ format(value) }}). Der Unterschied:

  • Pipe: deklarativ, wiederverwendbar über Komponenten hinweg, mit Caching auf Reference-Basis.
  • Methode im Template: läuft bei jedem Change-Detection-Cycle neu, auch wenn sich der Input nicht geändert hat — bei teurer Logik ein Performance-Problem.

Eine eigene Pipe lohnt sich, wenn die Transformation (a) nicht-trivial ist, (b) an mehreren Stellen verwendet wird oder (c) im Template stehen soll, ohne dass du jedes Mal eine Methode anlegst.

Eigene Pipe — Hello World

Eine eigene Pipe braucht den @Pipe-Decorator, einen name und eine transform()-Methode aus dem PipeTransform-Interface. Seit Angular 14 sind Pipes standardmäßig standalone (in v21 nicht mehr explizit nötig).

TypeScript truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
    transform(value: string): string {
        if (!value) return '';
        return value.length > 20 ? value.slice(0, 20) + '…' : value;
    }
}

Verwendung in einer Standalone-Komponente — die Pipe-Klasse landet im imports-Array, nicht in providers:

TypeScript article.component.ts
import { Component } from '@angular/core';
import { TruncatePipe } from './truncate.pipe';

@Component({
    selector: 'app-article',
    imports: [TruncatePipe],
    template: `<p>{{ headline | truncate }}</p>`,
})
export class ArticleComponent {
    headline = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
}

Das PipeTransform-Interface

Das offizielle Interface ist bewusst lose typisiert:

TypeScript pipe-transform.ts
interface PipeTransform {
    transform(value: any, ...args: any[]): any;
}

Für robusten Code solltest du die Signatur selbst eng typisieren. Generics machen die Pipe transparent für TypeScript:

TypeScript identity.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'identity' })
export class IdentityPipe implements PipeTransform {
    // Generic T: Eingabetyp == Ausgabetyp, kein 'any' im Aufrufer
    transform<T>(value: T): T {
        return value;
    }
}

implements PipeTransform ist optional, aber stark empfohlen — sonst merkst du Tippfehler im Methodennamen erst zur Laufzeit.

Pipe-Argumente übergeben

Argumente kommen nach dem Pipe-Namen, durch Doppelpunkt getrennt: {{ value | myPipe:arg1:arg2 }}. In der transform-Signatur werden sie zu zusätzlichen Parametern:

TypeScript truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

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

Im Template:

HTML truncate-usage.html
<p>{{ headline | truncate }}</p>             <!-- Default: 20, '…' -->
<p>{{ headline | truncate:40 }}</p>          <!-- max=40 -->
<p>{{ headline | truncate:40:' [mehr]' }}</p> <!-- max=40, ellipsis=' [mehr]' -->

Default-Werte in der Methoden-Signatur sind die sauberste Lösung für optionale Argumente — keine Sentinel-Werte, keine ??-Operatoren in der Pipe.

Pure Pipes (Default)

Eine Pipe ist standardmäßig pure. Pure heißt: Angular ruft transform() nur dann auf, wenn sich der Input ändert — und „ändert” bedeutet bei Primitiven Wertänderung, bei Objekten und Arrays Referenzänderung. Das Ergebnis wird gecached, bis die Reference wechselt.

TypeScript upper.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'upper' }) // pure: true ist Default
export class UpperPipe implements PipeTransform {
    transform(value: string): string {
        console.log('upper transform aufgerufen'); // selten zu sehen!
        return value.toUpperCase();
    }
}

Pure Pipes sind das Standardwerkzeug für Formatierungen: formatDate, truncate, slugify, currency. Sie sind billig in der Verwendung, weil Angular sie bei unveränderten Inputs einfach überspringt — selbst dutzende | pipe-Aufrufe in einer Liste sind kein Problem.

Pure Pipes mit Object/Array-Inputs

Hier liegt der häufigste Fehler. Eine pure Pipe re-evaluiert nicht, wenn du das Array oder Objekt mutierst — die Reference bleibt ja gleich.

TypeScript reverse.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'reverse' })
export class ReversePipe implements PipeTransform {
    transform<T>(value: T[]): T[] {
        return [...value].reverse();
    }
}

In der Komponente:

TypeScript list.component.ts
export class ListComponent {
    items = ['a', 'b', 'c'];

    // FALSCH: mutiert das Array — Pipe rendert NICHT neu
    addBroken() {
        this.items.push('d');
    }

    // RICHTIG: neue Reference — Pipe rendert neu
    addCorrect() {
        this.items = [...this.items, 'd'];
    }
}

Mit @for (item of items | reverse; track item) siehst du nach addBroken() weiterhin c, b, a, obwohl items intern ['a','b','c','d'] ist. Erst addCorrect() triggert die Pipe. Das ist kein Bug, sondern by design — und der Hauptgrund, warum Filter- und Sort-Pipes als Anti-Pattern gelten.

Impure Pipes

Mit pure: false schaltest du das Caching ab — die Pipe läuft bei jedem Change-Detection-Cycle neu, also potenziell mehrfach pro Sekunde. Eingebaute Beispiele: AsyncPipe (sie hört auf Observables und muss Werte „pushen” können) und JsonPipe (für Debugging praktisch, weil sie Mutationen sieht).

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

@Pipe({ name: 'timeAgo', pure: false })
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 `vor ${seconds}s`;
        if (seconds < 3600) return `vor ${Math.floor(seconds / 60)}min`;
        return `vor ${Math.floor(seconds / 3600)}h`;
    }
}

Diese Pipe muss impure sein, weil ihr Output vom Input allein nicht eindeutig bestimmbar ist — Date.now() ändert sich mit jedem Aufruf. Eine pure Variante würde einmal rendern und dann „eingefroren” bleiben, bis das Datum neu zugewiesen wird.

Wann ist impure tatsächlich sinnvoll?

Impure Pipes sind selten die richtige Wahl. Drei legitime Use-Cases:

  1. Async-WerteAsyncPipe hält intern ein Subscription/Subscription-Token und braucht jeden Cycle die Chance, einen neuen Wert ans Template zu pushen.
  2. Werte ohne Reference-ChangetimeAgo (siehe oben), Live-Statusanzeigen.
  3. DebuggingJsonPipe zeigt absichtlich Mutationen, weil sie sonst nutzlos wäre.

Für alles andere ist impure ein Smell. Wenn du einen gefilterten View brauchst, ist die Antwort nicht „eine impure Filter-Pipe” — sondern entweder ein computed()-Signal in der Komponente oder eine vorberechnete Property. Mehr dazu in Section 13.

Praxis: Filter-Pipe mit Predicate

Auch wenn Filter-Pipes problematisch sind — manchmal sind sie der pragmatischste Weg. Hier eine pure Filter-Pipe, die nur funktioniert, wenn das Source-Array immutable behandelt wird:

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

@Pipe({ name: 'filter' })
export class FilterPipe implements PipeTransform {
    transform<T>(items: readonly T[], predicate: (item: T) => boolean): T[] {
        if (!items || !predicate) return [...(items ?? [])];
        return items.filter(predicate);
    }
}

Komponente mit Such-Input — wichtig: das Predicate muss bei jedem Such-Term-Wechsel eine neue Funktion-Reference sein, sonst greift das Pipe-Caching:

TypeScript search.component.ts
import { Component, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FilterPipe } from './filter.pipe';

@Component({
    selector: 'app-search',
    imports: [FormsModule, FilterPipe],
    template: `
        <input [(ngModel)]="term" placeholder="Suche…" />
        @for (user of users | filter : matcher(); track user.id) {
            <li>{{ user.name }}</li>
        }
    `,
})
export class SearchComponent {
    term = signal('');
    users = signal([
        { id: 1, name: 'Anna' },
        { id: 2, name: 'Bernd' },
        { id: 3, name: 'Clara' },
    ]);

    // computed() liefert bei jedem term-Change eine NEUE Funktion-Reference
    matcher = computed(() => {
        const t = this.term().toLowerCase();
        return (u: { name: string }) => u.name.toLowerCase().includes(t);
    });
}

Praxis: SafeHtml-Pipe mit DomSanitizer

Ein klassischer Custom-Pipe-Use-Case: HTML-Strings als „trusted” markieren, damit [innerHTML] sie nicht escaped. Die Pipe ist injectable, also bekommt sie den DomSanitizer per inject():

TypeScript safe-html.pipe.ts
import { Pipe, PipeTransform, inject } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';

@Pipe({ name: 'safeHtml' })
export class SafeHtmlPipe implements PipeTransform {
    private sanitizer = inject(DomSanitizer);

    transform(value: string): SafeHtml {
        return this.sanitizer.bypassSecurityTrustHtml(value ?? '');
    }
}

Verwendung:

HTML safe-html-usage.html
<div [innerHTML]="markdownAsHtml | safeHtml"></div>

Pipes in TypeScript-Code direkt aufrufen

Pipes sind injectable Services. Du kannst sie also auch außerhalb des Templates verwenden — praktisch, wenn dieselbe Formatierungs-Logik in Komponente und Template laufen soll, ohne Duplikation.

TypeScript invoice.component.ts
import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { TruncatePipe } from './truncate.pipe';

@Component({
    selector: 'app-invoice',
    imports: [CurrencyPipe, TruncatePipe],
    providers: [CurrencyPipe, TruncatePipe], // hier providen, um sie zu injecten!
    template: `<p>{{ summary }}</p>`,
})
export class InvoiceComponent {
    private currency = inject(CurrencyPipe);
    private truncate = inject(TruncatePipe);

    summary = `${this.truncate.transform('Lange Beschreibung der Rechnung', 15)} ` +
              `— ${this.currency.transform(1234.5, 'EUR')}`;
}

Wichtig: Damit inject() funktioniert, muss die Pipe in providers stehenimports allein reicht nur für die Template-Verwendung.

Standalone vs. NgModule

Seit Angular 14 sind Pipes per Default standalone. In Standalone-Komponenten landet die Pipe im imports-Array. In klassischen NgModules dagegen in declarations:

TypeScript legacy.module.ts
// Klassisches NgModule (Legacy)
@NgModule({
    declarations: [MyComponent, TruncatePipe],
    exports: [TruncatePipe],
})
export class FeatureModule {}

// Standalone-Komponente (modern, Default ab v14)
@Component({
    imports: [TruncatePipe],
    template: ``,
})
export class MyComponent {}

Funktional gleichwertig — der Standalone-Weg ist seit v17 der empfohlene und mit v22 wird OnPush zudem zum Default-Strategy für Komponenten.

Wann besser computed() statt eigener Pipe?

Mit Signals hast du oft eine elegantere Alternative: ein computed()-Signal, das die Transformation in der Komponente vornimmt. Side-by-Side für denselben Use-Case („gefilterte Liste”):

TypeScript comparison.component.ts
import { Component, signal, computed } from '@angular/core';

@Component({
    selector: 'app-comparison',
    template: `
        <!-- Variante A: Pipe -->
        @for (u of users() | filter : matcher(); track u.id) { … }

        <!-- Variante B: computed() -->
        @for (u of filtered(); track u.id) { … }
    `,
})
export class ComparisonComponent {
    term = signal('');
    users = signal([/* … */]);

    matcher = computed(() => {
        const t = this.term().toLowerCase();
        return (u: { name: string }) => u.name.toLowerCase().includes(t);
    });

    // computed() ist meist die bessere Wahl: typisiert, testbar, ohne Pipe-Boilerplate
    filtered = computed(() =>
        this.users().filter((u) => u.name.toLowerCase().includes(this.term().toLowerCase()))
    );
}
KriteriumPure PipeImpure Pipecomputed()
Re-Run-TriggerReference-Changejeder CD-CycleSource-Signal-Change
Cachingja, pro Pipe-Instanzkeinesja, automatisch
Wiederverwendung über Komponentensehr gutsehr gutnur per shared Service
Typisierungmanuell (Generics)manuellimplizit aus Signal-Typen
Bevorzugt fürFormatierungAsync / Live-Werteabgeleiteten lokalen State

Faustregel: Brauchst du eine Transformation lokal in einer Komponente und ist die Source ein Signal, nimm computed(). Brauchst du sie in vielen Templates und ist sie eine reine Funktion ((value) => result), bau eine pure Pipe.

SituationEmpfehlung
Datum/Währung formatiereneingebaute Pipe
Truncate, Slugify, Initialseigene pure Pipe
Gefilterte/sortierte Liste lokalcomputed()
Live-Zeit, Countdownimpure Pipe oder Signal mit Timer
Observable im TemplateAsyncPipe (impure)
HTML als trusted markiereneigene pure Pipe (SafeHtml)

Häufige Stolperfallen bei eigenen Pipes

pure: false läuft bei JEDEM CD-Cycle

Eine impure Pipe wird bei jedem Change-Detection-Lauf neu evaluiert — bei Default-Strategy potenziell mehrfach pro Sekunde, bei großen Listen pro Item. Wenn die Transformation teuer ist (Regex, JSON-Parse, Date-Berechnung), wird das schnell zum Performance-Killer. Setze pure: false nur, wenn du den Grund klar benennen kannst.

Filter- und Sort-Pipes sind ein Anti-Pattern

Bei Mutation der Source-Liste greift die pure Pipe nicht — die Reference ist unverändert, die UI bleibt stehen. Eine impure Variante rennt zu oft. Die saubere Lösung ist computed() mit Signals oder eine vorberechnete Property in der Komponente. Die offizielle Doku empfiehlt explizit, Filter/Sort nicht als Pipe zu implementieren.

DomSanitizer-Bypass ist sicherheitsrelevant

bypassSecurityTrustHtml deaktiviert Angulars XSS-Schutz. Nur mit serverseitig sanitized HTML oder vertrauenswürdigem statischem Content verwenden — niemals mit User-Input. Bei Markdown am besten DOMPurify oder eine Markdown-Engine mit eingebautem Sanitizer vorschalten.

Pipe-Klassen sind injectable Services

Jede Pipe ist intern ein Provider — du kannst andere Services per inject() in den Konstruktor ziehen (wie im SafeHtml-Beispiel). Umgekehrt kannst du eine Pipe selbst injecten, wenn du sie als Provider registrierst und im Komponenten-Code wie eine normale Klasse aufrufen willst.

Standalone-Pipe gehört in imports, nicht in providers

Für die Template-Verwendung reicht imports: [MyPipe]. providers: [MyPipe] brauchst du nur, wenn du die Pipe per inject() direkt in TypeScript-Code aufrufen willst. Die beiden Arrays haben unterschiedliche Bedeutung — Verwechslung führt zu „NullInjectorError”.

Pipes haben kein ngOnInit, aber OnDestroy funktioniert

Lifecycle-Hooks gibt es für Pipes praktisch nicht — kein ngOnInit, kein ngAfterViewInit. OnDestroy dagegen funktioniert: class MyPipe implements PipeTransform, OnDestroy wird für Cleanup von Subscriptions oder Timern aufgerufen, wenn die Pipe-Instanz zerstört wird. Genau so macht es AsyncPipe intern.

Pure-Pipe-Caching ist pro Instanz, nicht global

Zwei Aufrufe von | myPipe im selben Template haben unabhängige Caches — Angular instanziiert pro Template-Slot eine eigene Pipe-Instanz. Bei einer rein berechnenden Pipe ist das egal, bei einer mit internem State (was du sowieso vermeiden solltest) führt es zu Verwirrung.

Pipe vs. computed() bei OnPush gleichwertig

Mit OnPush-Strategy (Default ab v22) re-evaluiert eine pure Pipe nur bei Reference-Change des Inputs — exakt wie ein computed() bei Signal-Change. Performance-mäßig sind beide nahezu identisch; computed() hat minimal mehr Overhead durch den Signal-Wrapper, dafür gewinnt es an Lesbarkeit und Typsicherheit. Bei lokaler Komponenten-Logik daher meist die bessere Wahl.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Pipes

Zur Übersicht