Angular Template Syntax Deep Dive: @if @for @switch @let, Bindings, Two-Way [2026]
Angular templates are HTML with extra superpowers. The superpowers are surprisingly small in number: a handful of binding syntaxes, four block-style control-flow constructs, two reference-variable forms, and one or two expression rules that surprise newcomers. This lesson catalogs every one of them.
This is lesson 2.2 of the Angular Tutorial, following lesson 2.1 which covered the component class. By the end you can read any Angular template, modern or legacy, and know exactly what each symbol does.
Five binding forms in one table #
Angular templates have five binding syntaxes; everything in a template is one of them. Memorize this and templates stop being mysterious:
| Form | Example | What it does |
|---|---|---|
| Interpolation | {{ user.name }} |
Render a value as text. Auto-escaped. |
| Property binding | [disabled]="!isValid()" |
Set a DOM property to the evaluated expression |
| Attribute binding | [attr.aria-label]="label" |
Set an HTML attribute (use when no DOM property exists) |
| Event binding | (click)="submit()" |
Run an expression when the named event fires |
| Two-way binding | [(value)]="name" |
Sugar for [value]="name" (valueChange)="name=$event" |
The first three move data into the DOM; the fourth moves data out; the fifth moves it in both directions. There are no other binding forms.
Interpolation: the workhorse #
<h1>{{ title }}</h1>
<p>Welcome, {{ user.name }}.</p>
<p>You have {{ items().length }} items.</p>
<p>Total: {{ total() | currency:'USD' }}</p>
Two facts:
- Interpolation auto-escapes.
{{ '<script>' }}renders the literal six characters, not an injected element. Lesson 8.9 covers security in depth. - Pipes can chain.
{{ price | currency:'USD' | uppercase }}runspricethroughcurrencythenuppercase.
What you cannot do in interpolation: assignment ({{ x = 5 }}), function declarations, increment/decrement ({{ x++ }}), new, typeof, or any side-effecting expression. The expression grammar is intentionally narrower than JavaScript to keep templates declarative.
Property binding: [propertyName] #
<button [disabled]="isLoading()">Submit</button>
<img [src]="avatarUrl" [alt]="user.name" />
<app-card [user]="current()" [highlighted]="isSelected()" />
The LHS is a DOM property (or an Angular component input). The RHS is any template expression. Angular sets element.disabled = isLoading(), not the HTML attribute.
For component inputs, the LHS matches an input() signal field name on the child:
export class CardComponent {
user = input.required<User>(); // <app-card [user]="..." />
highlighted = input(false); // <app-card [highlighted]="..." />
}
Attribute binding: [attr.attributeName] #
Some HTML attributes have no matching DOM property — aria-label, colspan, data-*. For those you use [attr.X]:
<button [attr.aria-label]="buttonLabel">×</button>
<td [attr.colspan]="3">Total</td>
<div [attr.data-track]="eventName">...</div>
The [attr.] prefix is required because [aria-label]="..." would try to set element.ariaLabel (which does exist) — but [colspan] and [data-*] would silently fail. Use [attr.X] when in doubt; it always works.
Event binding: (eventName) #
<button (click)="submit()">Submit</button>
<input (input)="search.set($event.target.value)" />
<form (submit)="$event.preventDefault(); save()">...</form>
<app-card (selected)="select($event)" />
Three facts:
$eventis the event payload. For DOM events it is a DOMEvent. For Angular outputs (lesson 2.5), it is whatever the parent emitted.- You can run multiple statements. Separate them with semicolons:
(submit)="$event.preventDefault(); save()". - Event handlers can be method calls or any statement.
(click)="count.set(count() + 1)"is valid; you do not need a wrapper method.
Two modifiers you will see in real code:
<!-- Run handler outside Angular's change detection (rare, for perf) -->
<div (@somethingDone)="onDone($event)"></div>
<!-- Listen on the window/document (lesson 2.13 host bindings is the cleaner option) -->
<div (window:resize)="onResize()"></div>
Two-way binding: [(prop)] #
Two-way binding is syntactic sugar for the combination of a property-binding and an event-binding. These two snippets are identical:
<!-- Sugar -->
<input [(value)]="name" />
<!-- Desugared -->
<input [value]="name" (valueChange)="name = $event" />
For a [(foo)] binding to work on a component, the component must expose a model<T>() (lesson 3.3) — that primitive owns the input + the event together. With native DOM elements, only [(value)] and [(ngModel)] (forms) commonly support it.
Control flow: @if @for @switch @let #
Since v17, Angular has built-in control flow with block syntax. These replaced *ngIf, *ngFor, and *ngSwitch directives entirely.
@if #
@if (user(); as u) {
<p>Hi, {{ u.name }}</p>
} @else if (isGuest()) {
<p>Welcome, guest</p>
} @else {
<button (click)="signIn()">Sign in</button>
}
The as u binding narrows the type — inside the block, u is non-null even when user() is typed User | null. Lesson 1.4 covered the type-narrowing story.
@for #
@for (item of items(); track item.id; let idx = $index) {
<li>{{ idx + 1 }}. {{ item.name }}</li>
} @empty {
<li>No items.</li>
}
The track clause is required. It is the per-item identity Angular uses to skip DOM recreation when the array changes. Use a stable key (item.id); never track $index unless the array is truly fixed-order. Lesson 8.3 covers the performance implications.
Available implicit variables inside @for:
| Variable | Value |
|---|---|
$index |
Zero-based index |
$first |
true for the first item |
$last |
true for the last item |
$even / $odd |
Alternating booleans |
$count |
Total array length |
Alias any of them with let idx = $index. The aliasing keeps the template tidy.
@switch #
@switch (state().status) {
@case ('idle') { <button>Load</button> }
@case ('loading') { <p>Loading...</p> }
@case ('success') { <p>{{ state().data.length }} items</p> }
@default { <p>Unknown state</p> }
}
Like @if, @switch narrows the type — inside @case ('success'), TypeScript knows the discriminant. This is why discriminated unions (lesson 1.4) are the cleanest way to model component state.
@let #
Declares a template-scoped variable:
@let fullName = user().firstName + ' ' + user().lastName;
@let displayPrice = price() * 1.1;
<p>Hello, {{ fullName }}!</p>
<p>Price: {{ displayPrice | currency }}</p>
@let is for derived values you reference more than once. It is computed at change-detection time and cached for the render. Use it instead of stuffing a complex expression into multiple interpolation sites.
Template reference variables: # #
A #name attribute creates a local reference to a DOM element or a child component:
<input #searchInput (input)="onSearch()" />
<button (click)="searchInput.focus()">Focus search</button>
<app-card #firstCard [user]="users()[0]" />
<button (click)="firstCard.flash()">Flash first card</button>
For DOM elements, the reference is the underlying HTMLElement. For components, it is the component class instance. You can call any public method or read any public field.
Safe navigation: ?. #
<p>{{ user?.address?.city ?? 'No address' }}</p>
Identical to TypeScript's optional chaining and nullish coalescing. Useful for migration code where the type system is not yet strict; in strictTemplates mode you mostly use @if (user(); as u) { u.address?.city } instead.
Pipes: | #
Pipes transform a value declaratively:
<p>{{ price | currency:'USD':'symbol':'1.2-2' }}</p>
<p>{{ posted | date:'medium' }}</p>
<p>{{ items() | async }}</p>
<p>{{ nested | json }}</p> <!-- great for debugging -->
Pipes are imported into the component's imports array. Lesson 2.8 covers every built-in pipe plus how to write your own.
Expression rules #
A few rules in template expressions catch newcomers:
| Rule | Why |
|---|---|
| No assignment except in event handlers | Templates should not mutate state during render |
| No bitwise operators | ` |
No new, typeof, instanceof |
Templates are declarative, not imperative |
| No statements (other than in events) | Only expressions render |
| Function calls allowed but evaluated every CD | Avoid expensive function calls in templates — use computed() instead |
That last one is the perf bullet you will hear about most: a function called in interpolation runs on every change-detection cycle. For derived values, prefer a computed() signal (memoized) over a method call (re-evaluated).
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
Parser Error: Got interpolation ({{}}) where expression was expected |
Used {{ }} inside a [binding]="..." attribute |
Drop the {{ }} inside [ ] — it's already an expression |
Can't bind to 'X' since it isn't a known property |
The component doesn't have an input named X, or the import is missing | Check the child's input() declarations; add to imports array if needed |
@for complains about track |
You omitted the required track clause |
Add track item.id (or whatever your stable key is) |
| Two-way binding fails on a custom component | The child exposed an input() but not a model() |
Switch the child to model() — [()] requires the paired primitive |
| Stuttery list updates | track $index on a reorderable array |
Switch to track item.id to preserve element identity |
What's next #
Lesson 2.3 introduces selectorless components — the v22 way to import a component class directly into the template without an imports array entry. Then 2.4 walks input() and input.required() in depth, 2.5 covers output(), 2.6 handles content projection. By the end of Module 2 every template idiom in this lesson will have made repeat appearances in context.
Try it yourself #
A tiny component using every block construct in this lesson:
@Component({
selector: 'app-task-list',
template: `
@let filtered = tasks().filter(t => t.status !== 'archived');
@if (filtered.length; as count) {
<p>{{ count }} active tasks</p>
<ul>
@for (t of filtered; track t.id; let last = $last) {
<li [class.last]="last">
@switch (t.priority) {
@case ('high') { 🔴 }
@case ('med') { 🟡 }
@default { ⚪ }
}
{{ t.title }}
</li>
}
</ul>
} @else {
<p>Inbox zero. 🎉</p>
}
`,
})
export class TaskListComponent {
tasks = signal<Task[]>([]);
}
Every block construct in one file: @if, @for, @switch, @let, and three binding forms. Tweak the array in tasks.set(...) and watch how Angular updates only the affected <li> thanks to track t.id.
onpush_zoneless_migrationStill valid through Angular 22, but you should migrate. The block-style @if/@for/@switch have better type narrowing, smaller emitted code, and no need to import NgIf/NgFor directives. The Angular CLI can migrate automatically — run ng generate @angular/core:control-flow in your project and it will rewrite every *ngIf/*ngFor/*ngSwitch in place. Review the diff, ship it.Lesson 2.3 picks up selectorless components — the cleanest way yet to share component classes across templates.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


