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-VersionStatus Built-in Control Flow
v17Stable, opt-in. Migration via ng generate verfügbar
v18Default für neu generierte Komponenten
v19CommonModule-Import in Standalone nicht mehr nötig
v20–v21Empfohlene 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.

HTML status-banner.component.html
@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

HTML vorher-nachher.html
<!-- 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>
}

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.

HTML user-card.component.html
<!-- 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.

HTML user-list.component.html
<!-- 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

HTML trackby-vs-track.html
<!-- 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.

VariableTypBedeutung
$indexnumberAktueller 0-basierter Index
$firstbooleantrue, wenn $index === 0
$lastbooleantrue, wenn $index === $count - 1
$evenbooleantrue bei geradem Index (0, 2, 4, …)
$oddbooleantrue bei ungeradem Index (1, 3, 5, …)
$countnumberGesamtanzahl der Elemente in der Liste
HTML striped-table.component.html
<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.

HTML nested-for.html
@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.

HTML search-results.component.html
<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.

HTML role-dashboard.component.html
@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:

HTML grouped-cases.html
@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).

TypeScript dashboard.component.ts
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.

TypeScript narrowing.component.ts
@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
ImplementierungStrukturdirektive, KlasseSprachelement, Compiler-emittierter Code
DOM-WrapperErzeugt EmbeddedView und Anker-KommentareDirektes Anhängen, weniger Anker-Knoten
Bundle-AnteilCode für NgIf/NgForOf/NgSwitch im BundleKein Direktiven-Code, nur Render-Instruktionen
track / trackByOptional bei *ngFor (Default: Referenz-Identität)Pflicht bei @for
Type-NarrowingEingeschränkt, nur via asVoll, auch ohne as
Import nötigCommonModule oder einzelne DirektivenNichts (Compiler-Feature)
Reuse-Verhalten bei Property-ÄnderungElement wird neu gemountetBindings 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.

Bash terminal
# 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/feature

Was die Migration tut:

  • Konvertiert *ngIf in @if, inklusive else-Templates und as-Aliassen.
  • Konvertiert *ngFor in @for, übernimmt trackBy-Methoden in track-Ausdrücke.
  • Konvertiert *ngSwitch/*ngSwitchCase/*ngSwitchDefault in @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 $index semantisch sinnvoll ist — wenn deine alte *ngFor ohne trackBy lief, landest du auf track $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

/ Weiter

Zurück zu Templates & Control Flow

Zur Übersicht