Angular Directives and Pipes: Complete Guide (2026) - Angular tutorial cover with bold white title lettering on a dark navy grid background.

Quick answer: Angular directives and pipes both transform what the template shows: directives change how an element behaves, pipes change how a value displays. Angular’s built-in control flow handles the old structural directives, but ngClass and ngStyle still style elements, and pipes such as date, currency and uppercase format values in the template. You can write your own directive or pipe when the built-ins do not fit.The sections below cover Angular directives and pipes in the order you need them: the two kinds of directive, writing your own, then the built-in pipes — and the Angular directives and pipes you can now replace with plain template syntax. Libraries extend the same mechanism: Angular Material ships attribute directives for tooltips, input hints and menu triggers, which is why its components behave like ordinary elements in a template.

Quick answer card that answers 'angular directives and pipes' in plain language for beginners.
angular directives and pipes guide roadmap listing the 4 sections in reading order.

Structural versus attribute directives

A structural directive adds or removes part of the DOM. For years that meant *ngIf and *ngFor; today the template language’s @if and @for do the same job and those directives are deprecated.

An attribute directive changes an existing element without adding or removing it. The two built-ins you will still import are ngClass and ngStyle.

<p [ngClass]="{ 'text-danger': hasError(), 'text-muted': !hasError() }">
  Status
</p>

<div [ngStyle]="{ width: percent() + '%' }"></div>

Both take an object whose keys are class names or CSS properties and whose values are booleans or values. That keeps conditional styling in the markup instead of in the class. For a single boolean, prefer the plain binding form — [class.text-danger]="hasError()" — which needs no import at all.

Writing a custom Angular directive

A directive is a class with the @Directive decorator. This one highlights any element it is attached to:

import { Directive, ElementRef, HostListener, inject } from '@angular/core';

@Directive({ selector: '[appHighlight]' })
export class HighlightDirective {
  private el = inject(ElementRef<HTMLElement>);

  @HostListener('mouseenter') onEnter() {
    this.el.nativeElement.style.background = '#ccfbf1';
  }

  @HostListener('mouseleave') onLeave() {
    this.el.nativeElement.style.background = '';
  }
}

The selector [appHighlight] means “match an attribute named appHighlight”. Use it as <p appHighlight>Hover me</p>. @HostListener subscribes to events on the host element; its partner @HostBinding sets host properties instead of reaching into the DOM directly.

@Directive({ selector: '[appBold]' })
export class BoldDirective {
  @HostBinding('style.fontWeight') weight = 'bold';
}

Prefer @HostBinding over writing styles through ElementRef. It keeps the component’s view encapsulated and is easier to test.

angular directives and pipes concept map connecting the related topics this guide covers.

Built-in Angular directives and pipes you will use often

PipeExampleResult
date{{ today | date:'mediumDate' }}Oct 17, 2026
currency{{ price | currency:'USD' }}$19.99
uppercase{{ name | uppercase }}ADA LOVELACE
number{{ ratio | number:'1.2-2' }}0.75
slice{{ items | slice:0:3 }}First three items

A pipe is written with a vertical bar. You can chain them: {{ name | lowercase | titlecase }}. Standalone components must import the pipe they use — for example import { CurrencyPipe } from '@angular/common'; — just like any other dependency.

Writing a custom pipe

A pipe is a class that implements PipeTransform. This one truncates long text with an ellipsis:

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

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

Arguments after the pipe name become extra parameters: {{ body | truncate:80 }}. By default a pipe reruns on every change detection cycle. Add pure: true for a pure pipe — the default — which recalculates only when its input changes, and keep expensive work out of pipes that run in long lists.

Next in the series: services and dependency injection. To learn directives and pipes on real UI, Ampersand Academy teaches Angular one-to-one.

angular directives and pipes guide coverage grid showing every section covered in this article.

Common angular directives and pipes pitfalls and how to avoid them

Angular directives and pipes are straightforward in isolation, but production code surfaces a handful of recurring mistakes. Knowing them before you hit them saves hours of debugging. This section walks through the most common ones in the order developers typically encounter them.

The first pitfall is forgetting to import a standalone pipe. In module-based Angular you added pipes to a shared module once. In standalone components every pipe must appear in the imports array of every component that uses it. Missing the import produces a compile error that reads like a template parse failure, which is confusing the first time you see it.

The second pitfall is mutating an array or object and wondering why a pure pipe does not update. A pure pipe compares input references, not contents. Push an item onto an array and the reference stays the same, so the pipe output stays the same. Either return a new array from your data source or mark the pipe pure: false — but be aware that an impure pipe runs on every change detection tick, which can hurt performance in long lists.

The third pitfall is reaching into the DOM through ElementRef inside a directive when @HostBinding would suffice. Direct DOM manipulation bypasses Angular’s renderer, breaks server-side rendering, and makes unit testing harder. Reserve ElementRef for cases where no binding equivalent exists, such as calling focus() or measuring layout dimensions.

Change detection, pure versus impure pipes, and performance

Understanding how change detection interacts with angular directives and pipes is essential once your templates grow beyond toy examples. Angular runs change detection on every asynchronous event: click, HTTP response, timer tick. Every impure pipe re-executes on each of those ticks for every binding that uses it.

Pure pipes — the default — only recalculate when Angular detects a new input reference or a new primitive value. That makes them very cheap in OnPush components because both the component and the pipe skip work unless a signal or input actually changes. Pair pure pipes with immutable data patterns for maximum efficiency.

Impure pipes have their place. The built-in async pipe is impure because it must react to Observable emissions that arrive asynchronously, not just to reference changes. If you write a pipe that filters a live search result as the user types, impure is correct — just keep the transform function fast and free of side effects.

A practical rule: if your pipe takes a primitive and returns a primitive, it is almost always safe as pure. If it takes a mutable collection and must reflect in-place mutations, either switch to impure or restructure the data flow so mutations produce new references. Angular directives and pipes both benefit from the same discipline of immutable inputs.

To benchmark, wrap a suspect pipe in a counter:

transform(value: string): string {
  console.count('pipeRuns');
  return value.toUpperCase();
}

transform(value: string): string { console.count('pipeRuns'); return value.toUpperCase(); }

Open DevTools, interact with the page, and read the count. More than one run per user action in a pure pipe means something upstream is producing new object references unnecessarily. Fix the source, not the pipe.

Combining angular directives and pipes in real-world workflows

Angular directives and pipes compose naturally in templates. A common real-world pattern is a data table that uses a structural loop, an attribute directive for row highlighting, and a pipe for cell formatting — all on the same element hierarchy. Getting the composition order right matters.

Consider a sales dashboard row:

<tr *ngFor="let sale of sales"
    appHighlight
    [appStatusClass]="sale.status">
  <td>{{ sale.amount | currency:'GBP' }}</td>
  <td>{{ sale.closedAt | date:'shortDate' }}</td>
</tr>

<tr *ngFor="let sale of sales" appHighlight [appStatusClass]="sale.status"> <td>{{ sale.amount | currency:'GBP' }}</td> <td>{{ sale.closedAt | date:'shortDate' }}</td> </tr>

Here appHighlight handles hover state, appStatusClass maps a status string to a CSS class, and two pipes format the display values. None of these responsibilities overlap. That separation is what makes the template readable and each piece independently testable.

When a directive needs data that a pipe already formats, do not share the pipe logic. Keep raw data in the component and let the directive and the pipe consume it independently. Directives work on the DOM; pipes work on values. Crossing that boundary produces tight coupling that is hard to unpick later.

Another workflow worth knowing: input directives. A directive placed on an <input> can normalise user input before it reaches the model — stripping whitespace, enforcing uppercase, masking a phone number. This is cleaner than doing the same work in a pipe, because pipes are one-way display transforms while directives can write back through @HostListener('input') and ElementRef.

Testing angular directives and pipes

Testing is where angular directives and pipes earn their keep. Both are small, focused classes with clear inputs and outputs, which makes them some of the easiest Angular code to unit-test correctly.

Testing pipes in isolation

A pipe is a plain class. Instantiate it directly; no TestBed required. The TruncatePipe from the earlier section tests in four lines:

const pipe = new TruncatePipe();
console.assert(pipe.transform('Hello world', 5) === 'Hello...');
console.assert(pipe.transform('Hi', 5) === 'Hi');

const pipe = new TruncatePipe(); console.assert(pipe.transform('Hello world', 5) === 'Hello...'); console.assert(pipe.transform('Hi', 5) === 'Hi');

Run that file with ts-node or include it in your test suite. Because the pipe has no dependencies, there is nothing to mock. If your pipe injects a service — for example a locale service — use TestBed.configureTestingModule only then. Do not reach for TestBed by default; it adds setup cost for no gain when the class is self-contained.

Test edge cases explicitly: empty string, null, undefined, and values exactly at the boundary. Pipes receive whatever the template expression evaluates to, and that can be null during initial load before data arrives. Guard for it in the transform or document that the pipe assumes a non-null value.

Testing directives with a minimal host component

Directives need a DOM element, so they do require TestBed. The lightest approach is a minimal host component declared inline in the test file. This avoids creating a separate fixture component just for testing.

Test the directive’s observable effect — a class that appears, a style that changes, an event that fires — rather than its internal state. That keeps tests coupled to behaviour, not implementation, so refactoring the directive internals does not break tests.

When a directive uses @HostListener, trigger DOM events through the fixture’s nativeElement and then call fixture.detectChanges(). Angular will propagate the event to the listener and update bindings. Check the resulting element state, not whether the listener method was called.

Decision guide: when to write angular directives and pipes versus use alternatives

Not every formatting or DOM need requires angular directives and pipes. The decision tree below helps you choose the right tool before writing any code.

Start with formatting. If you need to display a value differently from how it is stored — dates, currencies, text truncation, unit conversion — reach for a pipe. If the built-in pipes cover it, use them. If a one-liner in the component class covers it and the format is used in exactly one place, a getter is simpler than a pipe. Write a custom pipe only when the same transformation appears in multiple templates.

Move to behaviour. If you need to respond to DOM events or change an element’s appearance conditionally, ask whether plain binding handles it. [class.active]="isActive" and [style.opacity]="loading ? 0.5 : 1" need no directive at all. Reach for ngClass or ngStyle when the logic produces multiple classes or styles from a single object expression. Write a custom directive when the same event-handling or DOM manipulation repeats across multiple elements or components.

Consider server-side rendering. Directives that manipulate the DOM through ElementRef.nativeElement directly may fail in SSR environments where no real DOM exists. Use Angular’s Renderer2 instead of direct DOM access when SSR is a requirement. Pipes are pure functions over values and have no SSR concerns.

Consider accessibility. A directive that toggles visibility should use [attr.aria-hidden] alongside its visual change. Pipes have no accessibility surface on their own, but the value they produce is read by screen readers, so ensure formatted output is still meaningful when read aloud — currency symbols usually are, but truncation with an ellipsis should pair with a full-text title attribute or aria-label.

Migrating from ngIf and ngFor to built-in control flow

Modern Angular angular directives and pipes guidance now recommends replacing *ngIf and *ngFor with the @if and @for block syntax introduced in Angular 17. This migration is mechanical and Angular ships a schematic that does most of it automatically.

Run the schematic with:

ng generate @angular/core:control-flow

ng generate @angular/core:control-flow

The schematic rewrites *ngIf to @if, *ngIf="x; else tpl" to @if (x) { … } @else { … }, and *ngFor="let item of items; trackBy: fn" to @for (item of items; track item.id) { … }. Review the diff before committing. The schematic is accurate but does not understand semantic intent, so check that track expressions reference the right property.

After migration you can remove NgIf and NgFor from every imports array. If a component imported CommonModule solely for those two directives, remove CommonModule too and import only the specific pipes — DatePipe, CurrencyPipe — that it still needs. This reduces bundle size and makes dependencies explicit.

The new control flow syntax also unlocks @empty blocks for empty collections and @defer for lazy loading, neither of which was possible with structural directives alone. Angular directives and pipes built before Angular 17 continue to work unchanged, so migration is additive and low risk.

One thing the schematic does not touch: custom structural directives you wrote yourself using TemplateRef and ViewContainerRef. Those still work, and in some advanced cases — dynamic component loading, portal patterns — they remain the right tool. If your custom structural directive is a simple condition or loop wrapper, replace it with a control flow block. If it does something genuinely dynamic, keep it.

Understanding the full picture of angular directives and pipes — built-in versus custom, pure versus impure, old structural syntax versus new control flow — equips you to make deliberate choices rather than defaulting to whatever pattern you learned first. Each tool has a clear job; overlap between them is minimal when the responsibilities stay separated.

Frequently asked questions

What is the difference between a directive and a pipe?

A directive changes the behaviour or appearance of an element. A pipe transforms a value for display, such as formatting a date or a currency amount.

Are ngIf and ngFor still used in Angular?

They still work but are deprecated. Use the @if and @for control flow syntax, which is part of the template language and requires no imports.

What does @HostListener do in a directive?

It subscribes the directive to an event on its host element, such as mouseenter or click, so the directive can respond without adding a template handler.

Why do I have to import a pipe in a standalone component?

Standalone components declare their own dependencies. The pipe it uses, such as CurrencyPipe, must appear in the component’s imports array.

Should I use ngStyle or a CSS class?

Prefer a CSS class when the values are fixed, because the stylesheet is easier to maintain and cache. Use ngStyle or a style binding when the value is computed at runtime.

Last updated on · Written by