Advanced Attribute Directives — Injecting Services, Input Aliasing and Exportable Directives

Production attribute directives go beyond simple @HostBinding and @HostListener. Injecting services, exporting directive instances for template access, using input aliases, and composing multiple directives enable powerful, reusable DOM behaviour abstractions. A PermissionsDirective that shows or hides elements based on the authenticated user’s roles eliminates role-checking boilerplate from every component that needs permission-gated UI elements.

Advanced Directive Patterns

// ── PermissionsDirective — show/hide based on user role ──────────────────
// Usage: <button *appHasRole="'Admin'">Delete All</button>
// Also:  <div *appHasRole="['Admin', 'Moderator']">...</div>

@Directive({
  selector: '[appHasRole]',
  standalone: true,
})
export class HasRoleDirective implements OnInit, OnDestroy {
  private authService = inject(AuthService);
  private template    = inject(TemplateRef<any>);
  private viewContainer = inject(ViewContainerRef);
  private destroyRef  = inject(DestroyRef);

  @Input({ required: true, alias: 'appHasRole' })
  roles!: string | string[];

  ngOnInit(): void {
    this.authService.currentUser$.pipe(
      takeUntilDestroyed(this.destroyRef),
    ).subscribe(user => {
      const requiredRoles = Array.isArray(this.roles) ? this.roles : [this.roles];
      const hasRole = user !== null &&
        requiredRoles.some(role => user.roles.includes(role));

      this.viewContainer.clear();
      if (hasRole) {
        this.viewContainer.createEmbeddedView(this.template);
      }
    });
  }
}

// ── Exportable directive — template can call its methods ──────────────────
// Usage: <input appAutoResize #resizer="appAutoResize">
//        <button (click)="resizer.reset()">Reset</button>

@Directive({
  selector: 'textarea[appAutoResize]',
  standalone: true,
  exportAs: 'appAutoResize',   // makes #ref="appAutoResize" work in templates
  host: {
    '(input)': 'onInput()',
    '[style.overflow]': '"hidden"',
    '[style.resize]': '"none"',
  },
})
export class AutoResizeDirective implements AfterViewInit {
  private el = inject(ElementRef<HTMLTextAreaElement>);
  private renderer = inject(Renderer2);

  ngAfterViewInit(): void { this.resize(); }
  onInput(): void { this.resize(); }

  private resize(): void {
    const el = this.el.nativeElement;
    this.renderer.setStyle(el, 'height', 'auto');
    this.renderer.setStyle(el, 'height', el.scrollHeight + 'px');
  }

  // Public method accessible via #ref="appAutoResize"
  reset(): void {
    this.renderer.setProperty(this.el.nativeElement, 'value', '');
    this.renderer.setStyle(this.el.nativeElement, 'height', 'auto');
  }
}

// ── Input aliasing — attribute name differs from TypeScript property ───────
@Directive({
  selector: '[appTooltip]',
  standalone: true,
})
export class TooltipDirective {
  // Attribute: [appTooltip]="'message'"  →  TypeScript: this.message
  @Input({ alias: 'appTooltip' }) message = '';

  // Additional inputs use their own names
  @Input() tooltipDelay = 300;
  @Input() tooltipPosition: 'top' | 'bottom' | 'left' | 'right' = 'top';
}
// Usage: <button [appTooltip]="'Save post'" tooltipDelay="500">Save</button>
Note: The alias option on @Input() decouples the HTML attribute name from the TypeScript property name. Without an alias, @Input() appHighlight forces the TypeScript property to match the long attribute name. With @Input({ alias: 'appHighlight' }) color = '', the HTML attribute is [appHighlight]="'yellow'" but the TypeScript property is the clean name color. This is especially useful for directive selectors that follow the appXxx naming convention but need clean internal property names.
Tip: The exportAs metadata lets components access the directive instance directly in the template via a template reference variable. This is how Angular Material exposes its directives: #picker="matDatepicker" gives the template direct access to the MatDatepicker instance to call picker.open(). Use exportAs when your directive has public methods the template should be able to call — it is cleaner than communicating through event bindings for imperative operations.
Warning: When a directive uses both TemplateRef and ViewContainerRef (like HasRoleDirective), it functions as a structural directive even without the * prefix. Structural directives that manipulate the view container should be used with the * prefix for clarity. Without the prefix, the syntax is more verbose: [appHasRole]="'Admin'" on the element requires the directive to explicitly use the element’s ViewContainerRef, which may cause unexpected results if the element itself is also a component.

Common Mistakes

Mistake 1 — Forgetting to export directive from standalone component imports

❌ Wrong — using [appTooltip] in a template without adding TooltipDirective to the component’s imports: []; NG8001 error.

✅ Correct — add every directive used in a template to the standalone component’s imports: [] array.

Mistake 2 — Not cleaning up ViewContainerRef on changes (stale embedded views)

❌ Wrong — calling viewContainer.createEmbeddedView() on each role change without first calling viewContainer.clear(); duplicate views accumulate.

✅ Correct — always call viewContainer.clear() before conditionally creating a new embedded view.

🧠 Test Yourself

A directive has @Input({ alias: 'appHighlight' }) highlightColor = 'yellow'. The template has [appHighlight]="'pink'". What is the value of this.highlightColor inside the directive?