Docs
/
Angular
Chapter 3

03 — Directives & Pipes

Directives Overview

Directives add behavior to DOM elements. There are three types:

TypePurposeExample
ComponentDirective with a template<app-card />
StructuralChange DOM layout (add/remove elements)@if, @for, *ngIf, *ngFor
AttributeChange appearance or behavior[ngClass], [ngStyle], custom

Built-in Attribute Directives

ngClass — Dynamic CSS Classes

<!-- Object syntax -->
<div [ngClass]="{ active: isActive, 'text-danger': hasError, disabled: !enabled }">

<!-- Array syntax -->
<div [ngClass]="['base-class', isAdmin ? 'admin' : 'user']">

<!-- String syntax -->
<div [ngClass]="currentClasses">
currentClasses = 'bold italic underline';

ngStyle — Dynamic Inline Styles

<div [ngStyle]="{
  'font-size': fontSize + 'px',
  'background-color': isHighlighted ? 'yellow' : 'transparent',
  'width.%': widthPercent
}">

> Prefer [class.xxx] and [style.xxx] for single bindings — ngClass/ngStyle for multiple.

ngModel — Two-Way Binding (Forms)

// Requires FormsModule in imports
import { FormsModule } from '@angular/forms';

@Component({
  imports: [FormsModule],
  template: `<input [(ngModel)]="query"> <p>{{ query }}</p>`,
})
export class SearchComponent {
  query = '';
}

Creating a Custom Attribute Directive

Highlight Directive

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

@Directive({
  selector: '[appHighlight]',
  standalone: true,
})
export class HighlightDirective {
  @Input() appHighlight = 'yellow';          // Color input (same name as selector)
  @Input() highlightText = 'black';          // Text color

  constructor(private el: ElementRef<HTMLElement>) {}

  @HostListener('mouseenter')
  onMouseEnter() {
    this.el.nativeElement.style.backgroundColor = this.appHighlight;
    this.el.nativeElement.style.color = this.highlightText;
  }

  @HostListener('mouseleave')
  onMouseLeave() {
    this.el.nativeElement.style.backgroundColor = '';
    this.el.nativeElement.style.color = '';
  }
}
<p appHighlight>Default yellow highlight</p>
<p [appHighlight]="'lightblue'" highlightText="white">Blue highlight</p>

Click Outside Directive

@Directive({
  selector: '[appClickOutside]',
  standalone: true,
})
export class ClickOutsideDirective {
  @Output() appClickOutside = new EventEmitter<void>();

  constructor(private el: ElementRef) {}

  @HostListener('document:click', ['$event.target'])
  onClick(target: HTMLElement) {
    if (!this.el.nativeElement.contains(target)) {
      this.appClickOutside.emit();
    }
  }
}
<div class="dropdown" (appClickOutside)="closeDropdown()">
  <!-- dropdown content -->
</div>

Debounce Input Directive

@Directive({
  selector: '[appDebounce]',
  standalone: true,
})
export class DebounceDirective implements OnInit, OnDestroy {
  @Input() appDebounce = 300;                          // Debounce time in ms
  @Output() debounced = new EventEmitter<string>();

  private destroy$ = new Subject<void>();

  constructor(private el: ElementRef<HTMLInputElement>) {}

  ngOnInit() {
    fromEvent(this.el.nativeElement, 'input')
      .pipe(
        debounceTime(this.appDebounce),
        map((e: any) => e.target.value),
        distinctUntilChanged(),
        takeUntil(this.destroy$),
      )
      .subscribe(value => this.debounced.emit(value));
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
<input [appDebounce]="500" (debounced)="search($event)" placeholder="Search...">

Structural Directives (Legacy *ngIf / *ngFor)

> In Angular 17+, prefer the new control flow (@if, @for). These still work for older codebases.

<!-- *ngIf -->
<div *ngIf="user; else noUser">
  Welcome, {{ user.name }}
</div>
<ng-template #noUser>
  <p>Please log in</p>
</ng-template>

<!-- *ngFor -->
<li *ngFor="let item of items; index as i; trackBy: trackById">
  {{ i + 1 }}. {{ item.name }}
</li>

<!-- *ngSwitch -->
<div [ngSwitch]="role">
  <p *ngSwitchCase="'admin'">Admin Panel</p>
  <p *ngSwitchCase="'user'">User Dashboard</p>
  <p *ngSwitchDefault>Guest View</p>
</div>

Pipes Overview

Pipes transform displayed values in templates. They don't change the underlying data.

{{ value | pipeName:arg1:arg2 }}

Built-in Pipes

PipeExampleOutput
uppercase{{ 'hello' | uppercase }}HELLO
lowercase{{ 'HELLO' | lowercase }}hello
titlecase{{ 'hello world' | titlecase }}Hello World
date{{ today | date:'short' }}1/15/24, 9:30 AM
currency{{ 99.5 | currency:'USD' }}$99.50
decimal{{ 3.14159 | number:'1.2-2' }}3.14
percent{{ 0.85 | percent }}85%
json{{ obj | json }}JSON string
async{{ obs$ | async }}Latest emitted value
slice{{ items | slice:0:5 }}First 5 items
keyvalue{{ map | keyvalue }}Array of {key, value}

Date Pipe Formats

{{ today | date:'short' }}          <!-- 1/15/24, 9:30 AM -->
{{ today | date:'medium' }}         <!-- Jan 15, 2024, 9:30:00 AM -->
{{ today | date:'fullDate' }}       <!-- Monday, January 15, 2024 -->
{{ today | date:'yyyy-MM-dd' }}     <!-- 2024-01-15 -->
{{ today | date:'HH:mm' }}         <!-- 09:30 -->
{{ today | date:'EEEE, MMMM d' }} <!-- Monday, January 15 -->

Currency Pipe

{{ 1499.99 | currency }}              <!-- $1,499.99 (default USD) -->
{{ 1499.99 | currency:'EUR' }}        <!-- €1,499.99 -->
{{ 1499.99 | currency:'GBP':'code' }} <!-- GBP1,499.99 -->
{{ 1499.99 | currency:'INR':'symbol':'1.0-0' }} <!-- ₹1,500 -->

Async Pipe (Very Important)

The async pipe subscribes and unsubscribes automatically — no need for ngOnDestroy.

@Component({
  imports: [AsyncPipe],
  template: `
    @if (user$ | async; as user) {
      <h1>{{ user.name }}</h1>
    } @else {
      <p>Loading...</p>
    }

    @for (item of items$ | async ?? []; track item.id) {
      <app-item [data]="item" />
    }
  `,
})
export class ProfileComponent {
  user$ = this.userService.getProfile();
  items$ = this.itemService.getAll();

  constructor(
    private userService: UserService,
    private itemService: ItemService,
  ) {}
}

Chaining Pipes

{{ user.createdAt | date:'longDate' | uppercase }}
<!-- JANUARY 15, 2024 -->

{{ users | slice:0:5 | json }}

Creating Custom Pipes

Truncate Pipe

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

@Pipe({
  name: 'truncate',
  standalone: true,
})
export class TruncatePipe implements PipeTransform {
  transform(value: string, limit = 50, trail = '...'): string {
    if (!value) return '';
    return value.length > limit ? value.substring(0, limit) + trail : value;
  }
}
{{ longText | truncate:100:'…' }}

Time Ago Pipe

@Pipe({ name: 'timeAgo', standalone: true })
export class TimeAgoPipe implements PipeTransform {
  transform(value: string | Date): string {
    const date = new Date(value);
    const seconds = Math.floor((Date.now() - date.getTime()) / 1000);

    const intervals: [number, string][] = [
      [31536000, 'year'], [2592000, 'month'], [86400, 'day'],
      [3600, 'hour'], [60, 'minute'], [1, 'second'],
    ];

    for (const [secs, label] of intervals) {
      const count = Math.floor(seconds / secs);
      if (count >= 1) return `${count} ${label}${count > 1 ? 's' : ''} ago`;
    }
    return 'just now';
  }
}
{{ comment.createdAt | timeAgo }}    <!-- "3 hours ago" -->

File Size Pipe

@Pipe({ name: 'fileSize', standalone: true })
export class FileSizePipe implements PipeTransform {
  transform(bytes: number): string {
    if (bytes === 0) return '0 B';
    const units = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(1024));
    return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i];
  }
}

Pure vs Impure Pipes

// Pure (default) — only recalculates when input reference changes
@Pipe({ name: 'filter', pure: true })   // ✅ Performant

// Impure — recalculates on every change detection cycle
@Pipe({ name: 'filter', pure: false })  // ⚠️ Expensive — use sparingly

> Rule: Keep pipes pure. If you need impure behavior, consider using a computed signal or a method in the component instead.


Key Takeaways

  • Use [class.xxx] / [style.xxx] for single bindings; ngClass / ngStyle for multiple
  • Custom attribute directives use @HostListener for events and ElementRef for DOM access
  • The async pipe is essential — it auto-subscribes and auto-unsubscribes from observables
  • Always keep pipes pure for performance — avoid impure pipes on large lists
  • Prefer the new control flow (@if, @for) over *ngIf, *ngFor in Angular 17+
  • Custom pipes are great for formatting (truncate, timeAgo, fileSize, currency)