Union- und Intersection-Types in PHP 8.4 richtig einsetzen
AI generated
PHP · PHP 8.4 · Type System · Core Language
Union- und Intersection-Types korrekt deklarieren und nutzen
Von int|string bis zur Disjunctive Normal Form in PHP 8.4
<?php
8.4

PHP 8.0 hat native Union-Types eingeführt, PHP 8.1 folgte mit Intersection-Types für Interface-Kombinationen, und PHP 8.2 hat mit Disjunctive Normal Form beide Konzepte zusammengeführt. Wer diese Konstrukte richtig einsetzt, ersetzt vage PHPDoc-Kommentare durch echte, von der Engine geprüfte Typangaben und macht Signaturen für IDE-Vervollständigung und Static-Analysis-Tools wie PHPStan und Psalm sofort verständlich.

16 Min. Lesezeit Union-Types · Intersection-Types · DNF-Types · Standalone-Types PHP 8.0 · 8.1 · 8.2 · 8.4

1. Einordnung: Warum Union- und Intersection-Types eingeführt wurden

Vor PHP 8.0 kannte die Engine nur einzelne Typangaben oder den vollständigen Verzicht auf Typisierung. Mehrere akzeptierte Typen wurden ausschließlich per PHPDoc-Kommentar wie @param int|string $value dokumentiert, ohne dass die Engine diese Angabe zur Laufzeit prüfte. Ein Tippfehler im Kommentar fiel nie auf, weil PHP ihn schlicht ignorierte. Mit PHP 8.0 wurden native Union-Types eingeführt: Eine Parameter-, Property- oder Rückgabetypangabe wie int|string wird jetzt von der Engine selbst durchgesetzt und wirft bei einem Verstoß einen TypeError.

PHP 8.1 erweiterte das Typsystem um Intersection-Types, die einen anderen Zweck erfüllen: Statt "eines von mehreren Typen" verlangen sie "alle angegebenen Typen gleichzeitig". Ein Parameter vom Typ Countable&Iterator muss ein Objekt sein, das beide Interfaces implementiert. Vorher blieb nur der Weg, ein neues Kompositions-Interface wie CountableIterator anzulegen und alle betroffenen Klassen darauf umzustellen, ein Aufwand, der bei Interfaces aus Fremdcode oft gar nicht möglich war.

Beide Konstrukte lösen also unterschiedliche Probleme desselben Grundthemas: präzisere Typangaben ohne den Rückgriff auf mixed oder auf reine Dokumentation. Zusammen mit den in PHP 8.2 nachgereichten DNF-Types und Standalone-Types bilden Union-Types und Intersection-Types das Fundament des modernen PHP-Typsystems, das PHPStan und Psalm inzwischen fast vollständig aus dem nativen Code ableiten können, ohne zusätzliche Annotationen.

2. Union-Types deklarieren: int|string und Nullable via |null

Die Syntax für Union-Types reiht die erlaubten Typen mit einem senkrechten Strich aneinander: function setId(int|string $id): void deklariert einen Parameter, der entweder ein int oder ein string sein darf. Die Reihenfolge der Typen in der Deklaration hat keinen Einfluss auf die Typprüfung selbst, wirkt sich aber unter schwacher Typisierung, also ohne strict_types, auf die Coercion-Reihenfolge aus: PHP prüft die angegebenen Typen von links nach rechts und wandelt den übergebenen Wert in den ersten passenden Typ um.

Für nullable Werte gibt es zwei Schreibweisen mit leicht unterschiedlicher Semantik. ?int ist eine Kurzform für int|null und ausschließlich für genau einen Nicht-null-Typ erlaubt. Sobald mehr als ein Typ zusätzlich zu null vorkommt, etwa int|string|null, muss die vollständige Union-Schreibweise verwendet werden. Ein Ausdruck wie ?int|string ist kein gültiges Konstrukt. Property-Deklarationen folgen derselben Regel und erzwingen bei fehlendem |null einen TypeError, sobald ein Aufrufer explizit null übergibt.

Ein Detail, das häufig übersehen wird: Bei einer Union aus int|float bleibt ein übergebener int-Wert unter strict_types weiterhin als int deklariert und wird nicht automatisch zu float befördert, anders als bei einem einzelnen float-Parameter, der einen int-Wert stillschweigend verlustfrei zu float konvertiert. Wer innerhalb der Funktion mit float rechnet, muss den Wert selbst mit (float) casten oder per is_int() prüfen.


<?php

declare(strict_types=1);

final class OrderIdentifier
{
    /**
     * Accepts either a numeric order id or a legacy string reference.
     */
    public function __construct(
        private readonly int|string $id,
    ) {
    }

    // Nullable union: exactly one non-null type plus null
    public function findPrevious(): self|null
    {
        return null;
    }

    // Full union syntax required for more than one non-null type
    public function setRawValue(int|string|null $value): void
    {
        // int|float union does NOT auto-promote under strict_types
        $amount = $this->computeAmount(); // int|float
        $normalized = is_int($amount) ? (float) $amount : $amount;
    }

    private function computeAmount(): int|float
    {
        return 42;
    }
}

3. Intersection-Types deklarieren: Countable&Iterator und Anwendungsfälle

Intersection-Types verwenden das Und-Zeichen statt des senkrechten Strichs: function process(Countable&Iterator $collection): void verlangt ein Objekt, das gleichzeitig Countable und Iterator implementiert. Anders als eine einfache Union sind sie ausschließlich für Klassen- und Interface-Typen erlaubt. Eine Kombination mit Skalartypen wie int&string ergibt keinen Sinn, weil kein Wert gleichzeitig beide Typen erfüllen kann, und wird von der Engine als Fehler abgelehnt.

Der typische Anwendungsfall entsteht, wenn eine Funktion Fähigkeiten mehrerer Interfaces gleichzeitig benötigt, ohne dass ein gemeinsames drittes Interface existiert. Eine Reporting-Funktion, die sowohl über count() die Größe einer Sammlung ermitteln als auch mit foreach darüber iterieren muss, profitiert von Countable&Iterator, statt ein eigenes CountableIterator-Interface einzuführen und alle bestehenden Klassen darauf zu casten.

Wichtig ist die Unterscheidung zur klassischen Vererbung: Eine Intersection-Type-Deklaration verlangt nicht, dass eine einzelne konkrete Klasse beide Interfaces explizit gemeinsam deklariert, solange die Klasse tatsächlich beide erfüllt. Die Engine prüft zur Laufzeit gegen instanceof für jedes Interface einzeln. Fehlt eines der Interfaces, wirft PHP einen TypeError mit einer Meldung, die exakt benennt, welches Interface nicht erfüllt wurde.


<?php

declare(strict_types=1);

interface ExportableCollection extends Countable, Iterator
{
}

final class ReportGenerator
{
    /**
     * Requires an object implementing both Countable and Iterator.
     */
    public function summarize(Countable&Iterator $collection): string
    {
        $total = count($collection);
        $lines = [];

        foreach ($collection as $key => $value) {
            $lines[] = sprintf('%s: %s', $key, $value);
        }

        return sprintf('%d entries: %s', $total, implode(', ', $lines));
    }
}

4. DNF-Types (PHP 8.2): Union und Intersection in Klammern kombinieren

Bis PHP 8.1 ließen sich Union-Types und Intersection-Types nicht im selben Typausdruck mischen: (Countable&Iterator)|string war ein Syntaxfehler. PHP 8.2 hat mit Disjunctive Normal Form, kurz DNF-Types, genau diese Lücke geschlossen. Die Regel lautet: Eine Intersection-Gruppe muss in Klammern stehen, sobald sie Teil einer größeren Union ist, während einzelne Typen ohne Klammern direkt an die Union angehängt werden.

Ein praktisches Beispiel ist eine Methode, die entweder ein Objekt akzeptiert, das gleichzeitig Countable und Iterator implementiert, oder null: function process((Countable&Iterator)|null $data): void. Ohne die Klammern um die Intersection-Gruppe würde der Parser die Angabe nicht als gültigen Typ akzeptieren, weil eine Intersection niemals direkt neben einem senkrechten Strich ohne Gruppierung stehen darf.

DNF-Types erlauben keine beliebige Verschachtelung: Eine Intersection-Gruppe kann keinen Standalone-Type wie false oder true enthalten, und eine Union darf nicht direkt innerhalb einer Intersection-Gruppe vorkommen, also ist (A|B)&C ungültig. Diese Einschränkung hält die Typausdrücke eindeutig lesbar und vermeidet die kombinatorische Komplexität, die eine vollständig freie Verschachtelung mit sich bringen würde.


<?php

declare(strict_types=1);

final class BatchProcessor
{
    /**
     * DNF type: intersection group in parentheses combined with union.
     */
    public function process((Countable&Iterator)|null $data): int
    {
        if ($data === null) {
            return 0;
        }

        return count($data);
    }

    // Multiple intersection groups combined via union (DNF)
    public function describe((Countable&Iterator)|(Stringable&JsonSerializable) $value): string
    {
        return $value instanceof Countable
            ? sprintf('%d items', count($value))
            : (string) $value;
    }
}

5. Standalone-Types: false, true und null als eigenständige Rückgabetypen

Vor PHP 8.0 existierte false ausschließlich als Bestandteil einer Union wie int|false, etwa bei strpos(), das entweder die Fundstelle als int oder false bei Nichttreffer zurückgibt. Ein eigenständiger Rückgabetyp false war zunächst nicht vorgesehen, weil ein Wert, der immer false ist, im klassischen Typsystem wenig sinnvoll erschien. Für Interfaces, die von mehreren Implementierungen mit unterschiedlichem Verhalten erben, ist ein garantiertes false in der Basisklasse jedoch ein legitimer Anwendungsfall.

PHP 8.2 hat deshalb false und true als eigenständige Standalone-Types zugelassen. Eine Methode kann jetzt explizit function isSupported(): false deklarieren, wenn eine Basisimplementierung ein Feature grundsätzlich nicht unterstützt, während eine überschreibende Klasse denselben Methodennamen mit function isSupported(): bool deklariert. Das macht in der Klassenhierarchie sofort sichtbar, welche Implementierung das Feature niemals anbietet, ohne die Bedeutung im Kommentar erklären zu müssen.

Wichtig bei der Verwendung: false und true als Standalone-Type sind ausschließlich als Rückgabetyp sinnvoll, nicht als Parametertyp, weil ein Parameter vom Typ false den Aufrufer zwingen würde, immer denselben Literalwert zu übergeben, was keinen praktischen Vorteil gegenüber einem festen Default-Wert bietet. Auch die Kombination bool|false ist redundant, weil bool bereits sowohl true als auch false umfasst. PHPStan meldet diese Redundanz als Warnung.


<?php

declare(strict_types=1);

abstract class PaymentGateway
{
    abstract public function charge(int $amountInCents): bool;
}

final class LegacyOfflineGateway extends PaymentGateway
{
    /**
     * This gateway never supports online charging.
     */
    public function supportsOnlineCharge(): false
    {
        return false;
    }

    public function charge(int $amountInCents): bool
    {
        // Always fails for this legacy gateway
        return false;
    }
}

6. Typprüfung zur Laufzeit: match und instanceof mit Union-Types kombinieren

Anders als in Sprachen mit statischer Flusstypisierung engt PHP zur Laufzeit den Typ einer Variablen innerhalb einer Union nicht automatisch ein. Nach einem Parameter int|string $value weiß die Engine selbst innerhalb der Funktion nicht, welcher konkrete Typ tatsächlich vorliegt. Jede weitere Verarbeitung, die typspezifisches Verhalten braucht, muss den Typ explizit über is_int(), is_string() oder gettype() prüfen.

Für Union-Types aus mehreren Objekttypen ist match(true) in Kombination mit instanceof ein verbreitetes Pattern, weil match strikte Vergleiche durchführt und keinen impliziten Fallthrough kennt. Diese Struktur ersetzt lange if-elseif-Ketten und macht durch den expliziten default-Zweig sofort sichtbar, wenn ein neuer Typ zur Union hinzugefügt wurde, die zugehörige Prüfung dafür aber fehlt.

Bei Intersection-Types ist eine separate Typprüfung normalerweise unnötig, weil die Engine bereits beim Funktionsaufruf sicherstellt, dass alle beteiligten Interfaces erfüllt sind. instanceof-Prüfungen innerhalb der Funktion dienen hier höchstens dazu, zwischen mehreren konkreten Implementierungen zu unterscheiden, die alle dieselbe Intersection erfüllen, aber unterschiedliches Zusatzverhalten bieten.


<?php

declare(strict_types=1);

final class IdentifierResolver
{
    /**
     * Runtime narrowing for a union type parameter.
     */
    public function resolve(OrderId|CustomerId|string $value): string
    {
        return match (true) {
            $value instanceof OrderId => 'order:' . $value->toString(),
            $value instanceof CustomerId => 'customer:' . $value->toString(),
            is_string($value) => 'raw:' . $value,
        };
    }
}

7. Auswirkungen auf IDE-Vervollständigung und Static Analysis (PHPStan/Psalm)

Native Union-Types und Intersection-Types verändern die Qualität der IDE-Vervollständigung grundlegend, weil die IDE die tatsächliche Typinformation direkt aus der Signatur liest, statt sie aus einem PHPDoc-Kommentar zu parsen, der veralten oder falsch geschrieben sein kann. Sobald eine Variable innerhalb einer if-instanceof-Prüfung eingeengt wurde, bietet die IDE für den restlichen Block ausschließlich die Methoden des eingeengten Typs an, was Tippfehler in Methodennamen praktisch ausschließt.

PHPStan und Psalm gehen bei der Flow-Analyse noch einen Schritt weiter als die reine Engine-Prüfung: Nach einem is_string($value)-Guard in einer Union int|string erkennen beide Tools, dass innerhalb des if-Blocks nur noch string als Typ infrage kommt, und melden einen Fehler, sobald dort eine int-spezifische Operation aufgerufen wird. Diese Narrowing-Analyse funktioniert für beide Konstrukte gleichermaßen und ist einer der Hauptgründe, PHPStan mindestens auf Level 6 einzusetzen.

Für Fälle, die über native Intersection-Types hinausgehen, etwa generische Collections mit einem Type-Parameter, der selbst eine Intersection sein soll, bieten Psalm und PHPStan zusätzliche Template-Annotationen wie @template T of Countable&Iterator. Diese Erweiterung existiert, weil PHP selbst keine Generics kennt. Die native Syntax deckt nur den nicht-generischen Fall ab, während die docblock-basierten Templates die generische Ebene ergänzen.

8. Typische Fehler: zu breite Union-Types und fehlende Narrowing-Logik

Der häufigste Fehler ist eine zu breite Union-Type-Deklaration, die eigentlich als Ausrede für ein fehlendes Domain-Modell dient: function handle(array|string|int|bool $input) signalisiert, dass die Funktion mit fast jedem Wert umgehen können muss, was in der Praxis fast immer bedeutet, dass die aufrufende Seite unterschiedliche, schlecht abgegrenzte Verantwortlichkeiten in eine einzige Funktion gepresst hat. PHPStan akzeptiert diese Signatur zwar, meldet aber bei jeder typspezifischen Operation im Funktionskörper zusätzliche Prüfungen, weil kein Narrowing mehr möglich ist, sobald mehr als zwei oder drei Typen kombiniert werden.

Ein zweiter häufiger Fehler ist die fehlende Narrowing-Logik nach einer Union-Type-Prüfung: Ein Entwickler prüft mit is_string($value), verwendet im weiteren Verlauf aber trotzdem eine Methode, die nur auf dem Objektzweig der Union existiert, weil eine spätere Änderung der Union vergessen hat, den zugehörigen Zweig anzupassen. Genau das fängt PHPStan mit aktivierter Narrowing-Analyse zuverlässig ab. Ein reiner Codereview ohne Tool-Unterstützung übersieht solche Stellen häufig.

Bei Intersection-Types entsteht der typische Fehler durch das Vergessen der Klammerung in DNF-Kontexten oder durch den Versuch, einen Standalone-Type in eine Intersection-Gruppe zu packen, etwa (Countable&false), was die Engine als Fehler ablehnt, weil false kein Objekttyp ist und niemals gleichzeitig mit einem Interface erfüllt werden kann. Die folgende Tabelle stellt unsichere oder unpräzise Muster den jeweils empfohlenen Deklarationen gegenüber.

Aufgabe Unsicher / Unpräzise Empfohlene Deklaration Vorteil
Mehrere Typen akzeptieren @param int|string $id (nur PHPDoc) int|string $id Laufzeitprüfung durch die Engine
Optionalen Rückgabewert deklarieren mixed als Rückgabetyp Foo|null Präzise Typinformation für IDE und Analyzer
Mehrere Interfaces verlangen CountableIterator als neues Interface Countable&Iterator Kein zusätzlicher Typ nötig
Union und Intersection kombinieren Countable&Iterator|string (Syntaxfehler) (Countable&Iterator)|string DNF-Syntax ab PHP 8.2 korrekt
Legacy-Funktion mit garantiertem false bool als Rückgabetyp false als Standalone-Type Signalisiert eindeutig: nie true
Typprüfung nach Union-Parameter gettype()-Vergleich per String match(true) mit instanceof/is_* Type-Narrowing für Static Analysis

9. Vergleich zu PHPDoc-only-Typen und Migration bestehender Signaturen

Viele bestehende Codebasen aus der PHP-7-Ära dokumentieren Union-artige Signaturen ausschließlich per PHPDoc: /** @param int|string $id */ function setId($id). Diese Angabe wird von der Engine zur Laufzeit vollständig ignoriert. Nur ein Static-Analysis-Tool wie PHPStan liest den Kommentar aus und vergleicht ihn mit der tatsächlichen Verwendung. Fehlt das Tool im Build-Prozess, oder wird eine Warnung übersehen, bleibt die Diskrepanz zwischen Dokumentation und Realität unentdeckt, bis ein Fehler in Produktion auftritt.

Die Migration zu nativen Union- und Intersection-Types sollte schrittweise erfolgen: Zuerst werden einfache, nicht-generische Signaturen auf native Typen umgestellt, weil diese der Engine zur Laufzeit sofortige Absicherung bringen. Komplexere generische Fälle, etwa eine Collection mit einem Value-Type-Parameter, bleiben vorerst bei einer kombinierten Lösung aus nativer Grundtyp-Deklaration plus ergänzendem PHPDoc-Template, weil PHP native Generics nicht unterstützt.

Ein sinnvoller Zwischenschritt bei großen Legacy-Projekten ist eine PHPStan-Baseline, die bestehende PHPDoc-only-Stellen zunächst toleriert, während neuer Code verpflichtend native Union- und Intersection-Types verwenden muss. So wächst der Anteil an nativ typisiertem Code kontinuierlich, ohne dass ein einzelner großer Migrations-Sprint nötig wird, der in der Praxis selten vollständig abgeschlossen wird.

10. Zusammenfassung

Union-Types und Intersection-Types lösen zusammen ein Problem, das PHPDoc-Kommentare nur behelfsmäßig adressiert hatten: präzise, von der Engine zur Laufzeit geprüfte Typangaben für Parameter, Properties und Rückgabewerte. Seit PHP 8.0 erzwingt die Engine solche Deklarationen wie int|string mit einem TypeError bei Verstoß, seit PHP 8.1 ergänzen entsprechende Interface-Kombinationen wie Countable&Iterator die Möglichkeit, mehrere Interface-Anforderungen ohne zusätzliches Kompositions-Interface zu kombinieren.

PHP 8.2 hat mit DNF-Types und Standalone-Types die letzten Lücken geschlossen: (A&B)|C kombiniert beide Konzepte in einem Ausdruck, während false und true als eigenständige Rückgabetypen garantiertes Verhalten in Klassenhierarchien dokumentieren. Wer diese Konstrukte konsequent statt breiter mixed-Signaturen einsetzt und die Typprüfung zur Laufzeit mit match und instanceof ergänzt, profitiert von besserer IDE-Vervollständigung und zuverlässigerer Static Analysis durch PHPStan und Psalm.

Union- und Intersection-Types in PHP 8.4: Das Wichtigste auf einen Blick

Union-Types

int|string verlangt genau einen der angegebenen Typen. ?Type nur für einen Nicht-null-Typ, sonst volle Schreibweise mit |null.

Intersection-Types

Countable&Iterator verlangt alle angegebenen Typen gleichzeitig, meist bei Interfaces, ohne neues Kompositions-Interface.

DNF-Types (PHP 8.2)

(A&B)|C kombiniert Union und Intersection. Intersection-Gruppen müssen in Klammern stehen, keine Standalone-Types darin.

Standalone-Types

false und true als eigenständiger Rückgabetyp für garantiertes Verhalten in Klassenhierarchien.

11. FAQ: Union- und Intersection-Types in PHP 8.4

1Union-Types vs. Intersection-Types?
Erstere verlangen genau einen der angegebenen Typen, Letztere verlangen alle gleichzeitig, meist bei Interfaces.
2Seit wann existieren diese Types?
Seit PHP 8.0 beziehungsweise 8.1, DNF- und Standalone-Types kamen mit PHP 8.2 hinzu.
3Nullable Union-Type korrekt deklarieren?
Für einen Typ reicht ?Type. Für mehrere Typen zusätzlich zu null ist Type1|Type2|null nötig.
4Intersection-Types mit Skalartypen?
Nicht möglich, da kein Wert zwei Skalartypen gleichzeitig erfüllen kann.
5Was ist ein DNF-Type?
Kombination aus Union und Intersection, Intersection-Gruppen müssen in Klammern stehen, etwa (Countable&Iterator)|null.
6Was bedeutet Standalone-Type false?
Signalisiert garantiertes false als Rückgabewert, nützlich in Basisklassen ohne Feature-Unterstützung.
7Automatisches Type-Narrowing zur Laufzeit?
Nein, explizite Prüfung mit is_int(), is_string() oder instanceof plus match(true) nötig.
8Wie helfen PHPStan und Psalm?
Flow-Analyse engt den Typ nach einem Guard automatisch für den restlichen Codeblock ein, was die Engine selbst nicht tut.
9Häufigster Fehler bei Union-Types?
Zu breite Unions wie array|string|int|bool, die ein fehlendes Domain-Modell kaschieren und Narrowing verhindern.
10PHPDoc-only-Typen sofort ersetzen?
Einfache Fälle ja, schrittweise über eine Baseline. Generische Fälle brauchen weiterhin PHPDoc-Templates.

Mironsoft

PHP 8.4 Entwicklung, Type-Safety und Code-Qualität für Magento- und PHP-Projekte

Type-sichere PHP-Codebasis für euer nächstes Projekt?

Wir analysieren bestehenden PHP-Code, ersetzen breite mixed-artige Signaturen durch präzise, kombinierbare Typdeklarationen und richten PHPStan sowie Psalm auf höchstem sinnvollen Level ein, damit euer Type System Fehler abfängt, bevor sie in Produktion auftreten.

Code-Review

Analyse bestehender Signaturen auf zu breite Typdeklarationen und ungenutzte Interface-Kombinationen

Type-Migration

Schrittweise Migration von PHPDoc-only-Typen zu nativen Typdeklarationen

Static Analysis

PHPStan- und Psalm-Konfiguration inklusive Baseline-Strategie für Legacy-Code