Mit Version 17 hat Angular eine völlig neue Art eingeführt, Bedingungen und Schleifen direkt im Template auszudrücken: den Built-in Control Flow. Statt der bekannten Strukturdirektiven *ngIf, *ngFor und *ngSwitch schreibst du jetzt @if, @for und @switch — Blöcke, die der Template-Compiler nativ versteht.
Seit Angular 17 ist die neue Syntax stable, ab Angular 18 ist sie der Default für ng generate component. Mit Angular 21 (Nov 2025, aktueller Stand dieses Artikels) ist sie das absolute Standard-Werkzeug — kleiner, schneller, lesbarer und mit besserem TypeScript-Type-Narrowing als die Direktiven-Variante. Dieser Artikel zeigt dir alle drei Blöcke mit Tiefe, Edge-Cases und Migrationshinweisen.
Hintergrund: Warum eine neue Syntax?
Strukturdirektiven wie *ngIf und *ngFor haben Angular jahrelang treu gedient — aber sie sind im Kern Klassen, die Angular zur Laufzeit instanziieren, in den Component-Tree einhängen und über Eingaben füttern muss. Jede Direktive erzeugt einen zusätzlichen EmbeddedView, jede Direktive ist Code, der mit ins Bundle wandert, jede Direktive lebt am Type-System vorbei und macht TypeScript-Type-Narrowing in Templates schwer.
Der neue Built-in Control Flow löst alle vier Probleme gleichzeitig: @if, @for und @switch sind keine Direktiven, sondern Sprachelemente, die der Template-Compiler direkt in optimierte Render-Instruktionen übersetzt. Es gibt keine Importe, keine Klassen, keine Indirection. Der Compiler kennt die Bedingung und kann TypeScript-Narrowing korrekt anwenden — und das resultierende Bundle ist messbar kleiner.
| Angular-Version | Status Built-in Control Flow |
|---|---|
| v17 | Stable, opt-in. Migration via ng generate verfügbar |
| v18 | Default für neu generierte Komponenten |
| v19 | CommonModule-Import in Standalone nicht mehr nötig |
| v20–v21 | Empfohlene Form, *ngIf/*ngFor weiterhin supported |
@if — Bedingungen im Template
Die Grundsyntax ist nahe an JavaScript: @if (bedingung) { ... }, optional gefolgt von @else if (...) und @else. Die Blöcke werden vom Template-Compiler erkannt und in effiziente Render-Anweisungen übersetzt — ohne Strukturdirektive, ohne ng-template-Wrapper im Hintergrund.
@if (loading()) {
<p class="muted">Lade Daten…</p>
} @else if (error()) {
<p class="error">Fehler: {{ error()?.message }}</p>
} @else if (items().length === 0) {
<p class="info">Keine Treffer</p>
} @else {
<ul>
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
}Im Vergleich zur Direktive ist die neue Form sowohl kürzer als auch lesbarer: Du musst keinen ng-container-Wrapper mehr bemühen, um mehrere *ngIf-Stränge zu kombinieren, und @else if ist eine echte Sprachkonstruktion statt eines *ngIf="!cond1; ng-template-tpl"-Workarounds.
Direktvergleich: *ngIf vs. @if
<!-- Alt: *ngIf mit then/else, ng-template, CommonModule-Import -->
<p *ngIf="user(); else guestTpl">Hallo {{ user()!.name }}</p>
<ng-template #guestTpl>
<p>Bitte einloggen</p>
</ng-template>
<!-- Neu: @if, kein Wrapper, kein Import, Type-Narrowing inklusive -->
@if (user(); as u) {
<p>Hallo {{ u.name }}</p>
} @else {
<p>Bitte einloggen</p>
}as)Werte mit as im Block aliasieren
Wenn die Bedingung selbst der Wert ist, den du im Block brauchst — etwa ein Signal-Aufruf oder das Ergebnis eines async-Pipes — kannst du sie mit ; as alias an einen lokalen Namen binden. Der Wert wird einmal ausgewertet, im Block referenziert und ist getypt.
<!-- Signal-Aliasing: user() einmal aufrufen, im Block als u verwenden -->
@if (user(); as u) {
<article>
<h2>{{ u.name }}</h2>
<p>{{ u.email }}</p>
<small>Mitglied seit {{ u.joinedAt | date }}</small>
</article>
}
<!-- Async-Pipe: Replacement für *ngIf="user$ | async as user" -->
@if (user$ | async; as user) {
<p>Hallo {{ user.name }}</p>
}Das Pattern ersetzt den klassischen *ngIf="x$ | async as x"-Trick eins zu eins — und funktioniert nicht nur mit Observables, sondern mit jedem beliebigen Ausdruck, dessen Wert du im Block wiederverwenden willst.
@for und der Pflicht-Parameter track
Schleifen schreibst du mit @for (item of items; track keyExpression) { ... }. Der entscheidende Unterschied zu *ngFor: track ist nicht optional. Du musst angeben, wie Angular ein Listenelement von einem anderen unterscheiden soll.
Das ist Absicht. Ohne stabilen Track-Key kann Angular bei jeder Listen-Änderung nicht entscheiden, welcher DOM-Knoten zu welchem neuen Element gehört — und muss im Zweifel alles neu rendern. Mit gutem Track-Key bleibt die Identität der DOM-Knoten erhalten, Form-Inputs verlieren ihren Fokus nicht, Animationen springen nicht, und die Performance ist messbar besser.
<!-- Dynamische Liste: track auf eindeutiger ID -->
@for (user of users(); track user.id) {
<li>{{ user.name }}</li>
}
<!-- Statische Liste, die sich nie ändert: track $index ist OK -->
@for (label of ['Mo', 'Di', 'Mi', 'Do', 'Fr']; track $index) {
<th>{{ label }}</th>
}
<!-- Primitive Werte ohne ID: track auf dem Wert selbst -->
@for (tag of tags(); track tag) {
<span class="chip">{{ tag }}</span>
}Direktvergleich: *ngFor mit trackBy vs. @for mit track
<!-- Alt: trackBy braucht eine separate Methode in der Klasse -->
<li *ngFor="let user of users(); trackBy: trackById">
{{ user.name }}
</li>
<!-- in der TS-Klasse: trackById = (_: number, u: User) => u.id; -->
<!-- Neu: track-Ausdruck direkt im Template, keine Methode nötig -->
@for (user of users(); track user.id) {
<li>{{ user.name }}</li>
}Implizite Variablen: $index, $first, $last, $even, $odd, $count
Innerhalb eines @for-Blocks stehen sechs implizite Variablen ohne Aliasing zur Verfügung — du kannst sie direkt verwenden, ohne let i = index etc. zu schreiben.
| Variable | Typ | Bedeutung |
|---|---|---|
$index | number | Aktueller 0-basierter Index |
$first | boolean | true, wenn $index === 0 |
$last | boolean | true, wenn $index === $count - 1 |
$even | boolean | true bei geradem Index (0, 2, 4, …) |
$odd | boolean | true bei ungeradem Index (1, 3, 5, …) |
$count | number | Gesamtanzahl der Elemente in der Liste |
<table>
<tbody>
@for (row of rows(); track row.id) {
<tr [class.even]="$even" [class.odd]="$odd">
<td>{{ $index + 1 }} / {{ $count }}</td>
<td>{{ row.label }}</td>
@if (!$last) {
<td><hr /></td>
}
</tr>
}
</tbody>
</table>Wenn du eine implizite Variable umbenennen willst (etwa weil du verschachtelte @for-Blöcke hast und beide Indizes brauchst), nutzt du let outerIdx = $index als zusätzlichen Parameter — analog zu let i = index bei *ngFor.
@for (group of groups(); track group.id; let groupIdx = $index) {
<h3>Gruppe {{ groupIdx + 1 }}: {{ group.name }}</h3>
<ul>
@for (item of group.items; track item.id; let itemIdx = $index) {
<li>{{ groupIdx }}.{{ itemIdx }} – {{ item.label }}</li>
}
</ul>
}@empty — eleganter Leer-Zustand
Bei *ngFor musste man den Leer-Zustand außerhalb der Schleife mit einem zusätzlichen *ngIf abfangen. @for löst das mit einem dedizierten @empty-Block, der nur dann gerendert wird, wenn die Iterable keine Elemente liefert.
<h2>Suchergebnisse für „{{ query() }}"</h2>
@for (hit of results(); track hit.id) {
<article class="hit">
<h3>{{ hit.title }}</h3>
<p>{{ hit.snippet }}</p>
</article>
} @empty {
<p class="muted">
Keine Treffer für „{{ query() }}". Versuch einen anderen Suchbegriff.
</p>
}Vorher hätte man dieselbe Logik mit zwei Strukturdirektiven aufbauen müssen — und entweder results.length doppelt evaluieren oder einen let count = results.length-Trick benutzen. Mit @empty ist die Intention direkt im Template sichtbar.
@switch für endliche Auswahl
@switch arbeitet mit strikter Gleichheit (===) und erwartet @case-Blöcke pro Möglichkeit, optional einen @default-Block als Fallback. Anders als beim JavaScript-switch gibt es keinen Fallthrough — kein break nötig, jeder @case ist abgeschlossen.
@switch (currentRole()) {
@case ('admin') {
<app-admin-dashboard />
}
@case ('editor') {
<app-editor-dashboard />
}
@case ('viewer') {
<app-viewer-dashboard />
}
@default {
<app-guest-screen />
}
}Mehrere Cases zusammenfassen
Wenn mehrere Werte auf denselben Zweig führen, stapelst du die @case-Statements direkt übereinander:
@switch (status()) {
@case ('draft')
@case ('review') {
<app-editor-area />
}
@case ('published')
@case ('archived') {
<app-readonly-area />
}
@default {
<p>Unbekannter Status</p>
}
}Exhaustive Checks mit Discriminated Unions
Wenn du in Angular 21 in einem TypeScript-strict-Setup arbeitest und der Schalter-Wert ein Discriminated Union ist, prüft der Compiler die Vollständigkeit deiner @case-Zweige — fehlt ein Wert, gibt es einen Compiler-Fehler. Damit ersetzt @switch einen handgeschriebenen if/else if-Baum, der bei neuen Union-Mitgliedern stillschweigend leer bleiben würde.
Realistisches Beispiel: Auth → Rolle → Permissions
In echten Anwendungen kombinierst du alle drei Blöcke. Das folgende Beispiel zeigt eine Komponente, die zuerst den Auth-Status prüft (@if), dann anhand der Rolle ein Sub-Layout wählt (@switch) und schließlich eine Permission-Liste rendert (@for mit @empty).
import { Component, signal } from '@angular/core';
type Role = 'admin' | 'editor' | 'viewer';
interface User {
id: string;
name: string;
role: Role;
permissions: { id: string; label: string }[];
}
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
@if (user(); as u) {
<h1>Willkommen, {{ u.name }}</h1>
@switch (u.role) {
@case ('admin') {
<p>Du hast vollen Zugriff.</p>
}
@case ('editor') {
<p>Du kannst Inhalte bearbeiten.</p>
}
@case ('viewer') {
<p>Du hast Lese-Zugriff.</p>
}
}
<h2>Deine Berechtigungen</h2>
<ul>
@for (p of u.permissions; track p.id; let last = $last) {
<li>
{{ p.label }}
@if (!last) { <span>·</span> }
</li>
} @empty {
<li class="muted">Keine Berechtigungen vergeben</li>
}
</ul>
} @else {
<p>Bitte einloggen.</p>
}
`,
})
export class DashboardComponent {
user = signal<User | null>({
id: 'u-1',
name: 'Max Mustermann',
role: 'editor',
permissions: [
{ id: 'p-1', label: 'Artikel lesen' },
{ id: 'p-2', label: 'Artikel schreiben' },
],
});
}TypeScript versteht @if
Einer der unauffälligsten, aber wichtigsten Vorteile des Built-in Control Flow ist Type-Narrowing. Der Template-Compiler weiß, welche Bedingung in einem @if-Block gilt, und verengt den Typ der referenzierten Werte entsprechend — exakt so, wie es TypeScript in einem normalen if (x) { ... } tut.
@Component({
standalone: true,
template: `
<!-- user ist User | null -->
@if (user()) {
<!-- Hier ist user() vom Typ User, nicht User | null -->
<p>{{ user()!.name }}</p>
<!-- Tipp: mit "as u" geht es ohne Non-Null-Assertion -->
}
@if (user(); as u) {
<!-- u ist hier strikt User, kein null mehr -->
<p>{{ u.name }}</p>
<p>{{ u.email }}</p>
}
`,
})
export class NarrowingComponent {
user = signal<{ name: string; email: string } | null>(null);
}Bei *ngIf funktionierte das nur über die as-Variante (*ngIf="user() as u") — und auch da war das Narrowing in verschachtelten Direktiven oft fragil. Mit @if ist es kohärent und vorhersagbar.
Bei @switch greift Narrowing für Discriminated Unions automatisch: Innerhalb von @case ('admin') ist currentRole() strikt vom Typ 'admin', und Eigenschaften, die nur in dieser Variante existieren, sind getypt zugreifbar.
Was kostet die alte, was kostet die neue Form?
Die Performance-Vorteile des Built-in Control Flow sind keine Marketingsprache, sondern strukturell: Die alten Direktiven sind Klassen, die zur Laufzeit instanziiert werden, sie hängen einen ViewContainerRef und mindestens einen EmbeddedView in den Component-Tree — pro *ngIf, pro *ngFor-Iteration, pro *ngSwitchCase. Der neue Compiler emittiert direkten Render-Code: weniger Knoten, weniger Indirektion, weniger Code im Bundle.
| Aspekt | *ngIf / *ngFor / *ngSwitch | @if / @for / @switch |
|---|---|---|
| Implementierung | Strukturdirektive, Klasse | Sprachelement, Compiler-emittierter Code |
| DOM-Wrapper | Erzeugt EmbeddedView und Anker-Kommentare | Direktes Anhängen, weniger Anker-Knoten |
| Bundle-Anteil | Code für NgIf/NgForOf/NgSwitch im Bundle | Kein Direktiven-Code, nur Render-Instruktionen |
track / trackBy | Optional bei *ngFor (Default: Referenz-Identität) | Pflicht bei @for |
| Type-Narrowing | Eingeschränkt, nur via as | Voll, auch ohne as |
| Import nötig | CommonModule oder einzelne Direktiven | Nichts (Compiler-Feature) |
| Reuse-Verhalten bei Property-Änderung | Element wird neu gemountet | Bindings werden aktualisiert (kein Remount) |
Der Unterschied beim Reuse-Verhalten ist der wichtigste subtile Effekt der Migration: Bei *ngFor mit trackBy wurde ein DOM-Element neu gemountet, sobald der trackBy-Rückgabewert wechselte. @for aktualisiert in Place und behält das Element — Form-Inputs, Animationen und CSS-Transitions verhalten sich dadurch häufig stabiler, aber Komponenten, die im Konstruktor Initialisierung machen, sehen die Änderung anders.
Automatische Migration mit ng generate
Angular bringt eine Schematic mit, die *ngIf, *ngFor und *ngSwitch in einer ganzen Codebase auf die neue Syntax umschreibt — verlässlich und idempotent.
# Migration für die gesamte App ausführen
ng generate @angular/core:control-flow
# Optional auf einzelne Pfade beschränken
ng generate @angular/core:control-flow --path src/app/featureWas die Migration tut:
- Konvertiert
*ngIfin@if, inklusiveelse-Templates undas-Aliassen. - Konvertiert
*ngForin@for, übernimmttrackBy-Methoden intrack-Ausdrücke. - Konvertiert
*ngSwitch/*ngSwitchCase/*ngSwitchDefaultin@switch/@case/@default. - Entfernt nicht mehr benötigte
CommonModule-Importe in Standalone-Komponenten.
Was die Migration nicht tut:
- Sie löst keine verschachtelten
*ngIf-Konstruktionen auf — der erzeugte Code ist 1:1, nicht refaktoriert. - Sie entfernt keine
ng-container-Wrapper, die durch die neue Form überflüssig würden. - Sie überprüft nicht, ob ein
track $indexsemantisch sinnvoll ist — wenn deine alte*ngForohnetrackBylief, landest du auftrack $index, was bei dynamischen Listen falsch ist.
Besonderheiten
Keine Direktiven, sondern Sprachelemente
@if, @for und @switch sind keine Direktiven — sie werden vom Template-Compiler direkt in Render-Instruktionen übersetzt. Es gibt keinen NgIf- oder NgForOf-Code mehr im Bundle, kein zusätzlicher EmbeddedView pro Block, kein Component-Tree-Knoten zum Verwalten. Das Resultat: kleineres Bundle, schnellere Change Detection.
track ist Pflicht — und das ist gut so
Bei *ngFor war trackBy optional, was eine ganze Klasse von „die Liste flackert beim Update”-Bugs ermöglicht hat. @for erzwingt einen track-Ausdruck — Angular kann gar nicht erst in einen Zustand fallen, in dem es nicht weiß, welcher DOM-Knoten zu welchem Datum gehört.
Type-Narrowing greift wie in normalem TypeScript
Innerhalb von @if (user) ist user für TypeScript non-null. {{ user.name }} ist sicher, ohne Non-Null-Assertion. @switch verengt Discriminated Unions automatisch — innerhalb von @case (‘admin’) ist die Variable strikt vom Admin-Typ.
track $index nur bei statischen Listen
track $index ist nur sinnvoll, wenn sich die Reihenfolge und Länge der Liste nicht ändert (Wochentage, Sterne-Bewertung, Tabellen-Header). Bei sortierbaren oder filterbaren Listen ist track auf einer eindeutigen ID Pflicht — sonst denkt Angular, dass das Element an Position 2 immer dasselbe ist, obwohl der Inhalt komplett ausgetauscht wurde.
Migration ist konservativ, kein Refactoring
ng generate @angular/core:control-flow konvertiert 1:1. Sie löst keine verschachtelten ng-container/*ngIf-Verschachtelungen auf, die mit der neuen Syntax überflüssig wären. Plane nach der Auto-Migration einen kurzen manuellen Sweep zur Code-Vereinfachung ein.
@for aktualisiert in Place statt zu remounten
Wenn ein Property im track-Ausdruck wechselt, der Objekt-Reference aber gleich bleibt, aktualisiert @for die Bindings des bestehenden DOM-Elements — *ngFor hätte gemounted/unmounted. Konsequenz: stabilere Animationen und Form-States, aber Komponenten mit teurer Konstruktor-Logik werden seltener neu erzeugt.
@switch hat keinen Fallthrough
Anders als JavaScripts switch braucht es kein break — jeder @case ist abgeschlossen. Mehrere Cases mit gemeinsamem Block stapelt man, indem man die @case-Zeilen direkt untereinander schreibt und nur einen Block dahinterstellt.
Block-Scope für Variablen
Eine in @for deklarierte Helfer-Variable (etwa let outerIdx = $index) lebt nur in diesem Block. Du kannst sie nicht in einem geschwisterlichen @if oder einem äußeren @let referenzieren — der Scope endet mit der schließenden geschweiften Klammer.
Weiterführende Ressourcen
Externe Quellen
- Built-in Control Flow – Angular.dev
- Conditionals (@if) – Angular.dev
- Loops (@for, track) – Angular.dev
- Migration zu Control Flow – Angular.dev