Write directives that add and remove DOM, understand TemplateRef and ViewContainerRef, and add template type checking.

Bishal Biswas
Self Learner
Directives that change the DOM
An attribute directive changes an element. A structural directive decides whether the element exists at all, and how many times.
*ngIf and *ngFor are the built in examples. You can write your own.
import { Directive, TemplateRef, ViewContainerRef, inject, input, effect } from '@angular/core';
@Directive({
selector: '[appIf]',
})
export class AppIf {
private readonly template = inject(TemplateRef<unknown>);
private readonly container = inject(ViewContainerRef);
readonly condition = input.required<boolean>({ alias: 'appIf' });
constructor() {
effect(() => {
this.container.clear();
if (this.condition()) {
this.container.createEmbeddedView(this.template);
}
});
}
}
<p *appIf="isVisible()">Only when visible</p>
Two injections do everything.
TemplateRef is the markup the directive was applied to, captured rather than rendered.
ViewContainerRef is the place where views can be inserted.
The asterisk
The * is shorthand. Angular expands it into an ng-template.
<!-- what you write -->
<p *appIf="isVisible()">Hello</p>
<!-- what Angular sees -->
<ng-template [appIf]="isVisible()">
<p>Hello</p>
</ng-template>
That expansion explains why TemplateRef is injectable: the directive is on an ng-template, not on the paragraph.
It also explains why two structural directives cannot share an element. Both want to be the ng-template.
Passing extra values
A structural directive with several inputs uses a naming convention. Inputs are the selector name plus a suffix, and the microsyntax maps them.
@Directive({
selector: '[appRepeat]',
})
export class Repeat {
private readonly template = inject(TemplateRef<RepeatContext>);
private readonly container = inject(ViewContainerRef);
readonly times = input.required<number>({ alias: 'appRepeat' });
constructor() {
effect(() => {
this.container.clear();
for (let i = 0; i < this.times(); i++) {
this.container.createEmbeddedView(this.template, {
$implicit: i,
index: i,
isFirst: i === 0,
});
}
});
}
}
interface RepeatContext {
$implicit: number;
index: number;
isFirst: boolean;
}
<p *appRepeat="3; let i = index; let first = isFirst">
Row {{ i }} {{ first ? '(first)' : '' }}
</p>
$implicit binds to a bare let x. Named keys bind with let x = key.
Template type checking
By default the context is untyped, so let i = index gives any. A static ngTemplateContextGuard fixes that.
@Directive({ selector: '[appRepeat]' })
export class Repeat {
readonly times = input.required<number>({ alias: 'appRepeat' });
static ngTemplateContextGuard(
directive: Repeat,
context: unknown,
): context is RepeatContext {
return true;
}
}
Now let i = index is typed as number, and a typo in the key is a build error.
Narrowing types
*ngIf narrows a nullable value inside the block. Your directive can too, with a second guard.
@Directive({ selector: '[appIfPresent]' })
export class IfPresent<T> {
readonly value = input.required<T | null | undefined>({ alias: 'appIfPresent' });
static ngTemplateGuard_value: 'binding';
static ngTemplateContextGuard<T>(
directive: IfPresent<T>,
context: unknown,
): context is { $implicit: T } {
return true;
}
}
The ngTemplateGuard_value: 'binding' declaration tells the compiler that the template only renders when the binding is truthy, so it can narrow accordingly.
This is fiddly, and it is why @if is better for the common case. Reserve custom structural directives for genuinely reusable behaviour.
A useful example: permissions
@Directive({
selector: '[appHasRole]',
})
export class HasRole {
private readonly template = inject(TemplateRef<unknown>);
private readonly container = inject(ViewContainerRef);
private readonly auth = inject(AuthService);
readonly role = input.required<string>({ alias: 'appHasRole' });
constructor() {
effect(() => {
const allowed = this.auth.roles().includes(this.role());
this.container.clear();
if (allowed) this.container.createEmbeddedView(this.template);
});
}
}
<button *appHasRole="'admin'" (click)="deleteEverything()">Delete</button>
Cleaner than repeating @if (auth.roles().includes('admin')) everywhere, and the rule lives in one place.
This is a display concern only. Never rely on it for security. The button not rendering does not stop anyone calling the API.
Reusing views instead of recreating
Clearing and recreating on every change is simple and wasteful. Track the created view and only change it when needed.
@Directive({ selector: '[appIf]' })
export class AppIf {
private readonly template = inject(TemplateRef<unknown>);
private readonly container = inject(ViewContainerRef);
private view: EmbeddedViewRef<unknown> | null = null;
readonly condition = input.required<boolean>({ alias: 'appIf' });
constructor() {
effect(() => {
const shouldShow = this.condition();
if (shouldShow && !this.view) {
this.view = this.container.createEmbeddedView(this.template);
} else if (!shouldShow && this.view) {
this.container.clear();
this.view = null;
}
});
}
}
Now toggling does not destroy and rebuild an unchanged view.
Should you write one?
Usually not. @if and @for cover the common cases with better types and no imports.
Genuinely good reasons:
- A cross cutting rule used in many places, like permissions or feature flags.
- Integrating with something that needs
ViewContainerRef, such as a virtual scroller. - A library exposing declarative behaviour.
Bad reasons: replicating @if with a different name, or hiding a condition that would be clearer written out.
Quick recap
- A structural directive controls whether and how often its element exists.
- Inject
TemplateReffor the markup andViewContainerReffor where to put it. - The
*expands to anng-template, which is why only one is allowed per element. - Pass context with
$implicitand named keys, read withlet x = key. - Add
ngTemplateContextGuardso the context is typed rather thanany. - Track the created view instead of clearing and recreating on every change.
- Prefer
@ifand@forunless the behaviour is genuinely reusable.
import { Directive, TemplateRef, ViewContainerRef, EmbeddedViewRef,
inject, input, effect } from '@angular/core';
// TemplateRef = the markup, captured rather than rendered
// ViewContainerRef = the place views can be inserted
@Directive({ selector: '[appRepeat]' })
export class Repeat {
private readonly template = inject(TemplateRef<RepeatContext>);
private readonly container = inject(ViewContainerRef);
readonly times = input.required<number>({ alias: 'appRepeat' });
constructor() {
effect(() => {
this.container.clear();
for (let i = 0; i < this.times(); i++) {
// $implicit binds to a bare `let x`, named keys to `let x = key`
this.container.createEmbeddedView(this.template, {
$implicit: i, index: i, isFirst: i === 0,
});
}
});
}
// Without this the context is `any`. With it, `let i = index` is a number
// and a typo in the key is a build error.
static ngTemplateContextGuard(d: Repeat, ctx: unknown): ctx is RepeatContext {
return true;
}
}
interface RepeatContext { $implicit: number; index: number; isFirst: boolean; }
// Used as: <p *appRepeat="3; let i = index; let first = isFirst">
// The * is shorthand. Angular expands it to an ng-template, which is why
// TemplateRef is injectable and why two structural directives cannot share
// one element: both want to BE the ng-template.
// <p *appIf="x">Hi</p>
// becomes <ng-template [appIf]="x"><p>Hi</p></ng-template>
// A genuinely useful one: permissions in a single place
@Directive({ selector: '[appHasRole]' })
export class HasRole {
private readonly template = inject(TemplateRef<unknown>);
private readonly container = inject(ViewContainerRef);
private readonly auth = inject(AuthService);
private view: EmbeddedViewRef<unknown> | null = null;
readonly role = input.required<string>({ alias: 'appHasRole' });
constructor() {
effect(() => {
const allowed = this.auth.roles().includes(this.role());
// Reuse the view instead of clearing and rebuilding every change
if (allowed && !this.view) {
this.view = this.container.createEmbeddedView(this.template);
} else if (!allowed && this.view) {
this.container.clear();
this.view = null;
}
});
}
}
// <button *appHasRole="'admin'" (click)="remove()">Delete</button>
// This is a DISPLAY concern only. Not rendering the button does not stop
// anyone calling the API. Authorise on the server.
// Usually you want @if and @for: better types, no imports, no guards.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready