Union Types and Intersection Types in PHP 8.4 Done Right
AI generated
PHP · PHP 8.4 · Type System · Core Language
Declaring Union Types and Intersection Types Correctly
From int|string to Disjunctive Normal Form in PHP 8.4
<?php
8.4

PHP 8.0 introduced native union types, PHP 8.1 followed with intersection types for combining interfaces, and PHP 8.2 tied both concepts together with disjunctive normal form. Using these constructs correctly replaces vague PHPDoc comments with real, engine-checked type declarations and makes signatures instantly readable for IDE completion and static analysis tools such as PHPStan and Psalm.

16 min read Union Types · Intersection Types · DNF Types · Standalone Types PHP 8.0 · 8.1 · 8.2 · 8.4

1. Context: Why Union Types and Intersection Types Were Introduced

Before PHP 8.0, the engine only understood a single type declaration or no type declaration at all. Multiple accepted types were documented exclusively through a PHPDoc comment such as @param int|string $value, without the engine ever checking that statement at runtime. A typo in the comment never surfaced, because PHP simply ignored it. PHP 8.0 introduced native union types: a parameter, property, or return type declaration such as int|string is now enforced by the engine itself and throws a TypeError on violation.

PHP 8.1 extended the type system with intersection types, which serve a different purpose: instead of "one of several types" they require "all of the given types at once". A parameter of type Countable&Iterator must be an object that implements both interfaces. Before this addition, the only option was to create a new composite interface such as CountableIterator and switch every affected class over to it, an effort that was often impossible for interfaces coming from third-party code.

Both constructs therefore solve different problems within the same overall theme: more precise type declarations without falling back to mixed or to documentation alone. Together with the DNF types and standalone types added in PHP 8.2, union types and intersection types form the foundation of the modern PHP type system, which PHPStan and Psalm can now derive almost entirely from native code, without extra annotations.

2. Declaring Union Types: int|string and Nullable via |null

The syntax for union types lists the allowed types separated by a pipe character: function setId(int|string $id): void declares a parameter that may be either an int or a string. The order of the types in the declaration has no effect on the type check itself, but it does affect the coercion order under weak typing, that is without strict_types: PHP checks the listed types from left to right and converts the passed value to the first matching type.

Nullable values can be written in two ways with slightly different semantics. ?int is shorthand for int|null and is only allowed for exactly one non-null type. As soon as more than one type appears alongside null, for example int|string|null, the full union syntax must be used. An expression such as ?int|string is not a valid construct. Property declarations follow the same rule and raise a TypeError whenever a caller explicitly passes null and |null is missing.

A detail that is frequently overlooked: in a union of int|float, a passed int value stays declared as int under strict_types and is not automatically promoted to float, unlike a single float parameter, which silently and losslessly converts an int value to float. Anyone computing with float inside the function must cast the value manually with (float) or check it with is_int().


<?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. Declaring Intersection Types: Countable&Iterator and Use Cases

Intersection types use the ampersand instead of the pipe character: function process(Countable&Iterator $collection): void requires an object that implements both Countable and Iterator at the same time. Unlike a plain union, they are only allowed for class and interface types. Combining them with scalar types such as int&string makes no sense, since no value can satisfy two scalar types at once, and the engine rejects it as an error.

The typical use case arises when a function needs the capabilities of several interfaces at once, without a shared third interface already existing. A reporting function that needs to determine the size of a collection via count() and also iterate over it with foreach benefits from Countable&Iterator, instead of introducing a dedicated CountableIterator interface and casting every existing class to it.

It is important to distinguish this from classical inheritance: an intersection type declaration does not require a single concrete class to declare both interfaces together explicitly, as long as the class actually satisfies both. The engine checks each interface individually against instanceof at runtime. If one of the interfaces is missing, PHP throws a TypeError with a message that names exactly which interface was not satisfied.


<?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): Combining Union and Intersection in Parentheses

Up to PHP 8.1, union types and intersection types could not be mixed within the same type expression: (Countable&Iterator)|string was a syntax error. PHP 8.2 closed exactly this gap with disjunctive normal form, or DNF types for short. The rule is straightforward: an intersection group must be wrapped in parentheses as soon as it is part of a larger union, while individual types are appended to the union without parentheses.

A practical example is a method that accepts either an object implementing both Countable and Iterator, or null: function process((Countable&Iterator)|null $data): void. Without the parentheses around the intersection group, the parser would not accept the declaration as a valid type, because an intersection can never sit directly next to a pipe character without being grouped.

DNF types do not allow arbitrary nesting: an intersection group cannot contain a standalone type such as false or true, and a union may not appear directly inside an intersection group, so (A|B)&C is invalid. This restriction keeps type expressions unambiguously readable and avoids the combinatorial complexity that fully free nesting would bring.


<?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, and null as Dedicated Return Types

Before PHP 8.0, false only existed as part of a union such as int|false, as used by strpos(), which returns either the match position as an int or false when nothing is found. A dedicated false return type was not initially planned, because a value that is always false seemed of little use in the classical type system. For interfaces inherited by several implementations with different behavior, however, a guaranteed false in a base class is a legitimate use case.

PHP 8.2 therefore allowed false and true as dedicated standalone types. A method can now explicitly declare function isSupported(): false when a base implementation fundamentally does not support a feature, while an overriding class declares the same method name with function isSupported(): bool. This makes it immediately visible within the class hierarchy which implementation never offers the feature, without having to explain the meaning in a comment.

An important detail: false and true as standalone types only make sense as return types, not as parameter types, since a parameter of type false would force the caller to always pass the same literal value, which offers no practical advantage over a fixed default value. The combination bool|false is also redundant, because bool already covers both true and false. PHPStan reports this redundancy as a warning.


<?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. Runtime Type Checks: Combining match and instanceof with Union Types

Unlike languages with static flow typing, PHP does not automatically narrow the type of a variable within a union at runtime. After a parameter int|string $value, the engine itself does not know inside the function which concrete type is actually present. Any further processing that requires type-specific behavior must check the type explicitly via is_int(), is_string(), or gettype().

For union types made up of several object types, match(true) combined with instanceof is a common pattern, because match performs strict comparisons and has no implicit fallthrough. This structure replaces long if-elseif chains and, through the explicit default branch, immediately reveals when a new type has been added to the union but the corresponding check is still missing.

For intersection types, a separate type check is usually unnecessary, since the engine already guarantees at the function call that every involved interface is satisfied. instanceof checks inside the function are, at most, useful for distinguishing between several concrete implementations that all satisfy the same intersection but offer different additional behavior.


<?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. Impact on IDE Completion and Static Analysis (PHPStan/Psalm)

Native union types and intersection types fundamentally change the quality of IDE completion, because the IDE reads the actual type information directly from the signature instead of parsing it out of a PHPDoc comment that may be outdated or simply wrong. As soon as a variable has been narrowed inside an if-instanceof check, the IDE offers only the methods of the narrowed type for the rest of the block, which practically eliminates typos in method names.

PHPStan and Psalm go a step further with flow analysis than the plain engine check: after an is_string($value) guard within a union int|string, both tools recognize that only string remains a valid type inside the if block, and report an error as soon as an int-specific operation is called there. This narrowing analysis works equally well for both constructs, and is one of the main reasons to run PHPStan at level 6 or higher.

For cases that go beyond native intersection types, such as generic collections whose value type parameter is itself meant to be an intersection, Psalm and PHPStan offer additional template annotations such as @template T of Countable&Iterator. This extension exists because PHP itself has no generics. The native syntax only covers the non-generic case, while docblock-based templates fill in the generic layer.

8. Common Mistakes: Overly Broad Union Types and Missing Narrowing

The most common mistake is an overly broad union type declaration that really serves as an excuse for a missing domain model: function handle(array|string|int|bool $input) signals that the function has to handle almost any value, which in practice almost always means the calling side has crammed several poorly separated responsibilities into a single function. PHPStan does accept this signature, but flags additional checks for every type-specific operation inside the function body, because narrowing is no longer feasible once more than two or three types are combined.

A second common mistake is missing narrowing logic after a union type check: a developer checks with is_string($value), but later still calls a method that only exists on the object branch of the union, because a later change to the union forgot to update the corresponding branch. This is exactly what PHPStan reliably catches once narrowing analysis is enabled. A plain code review without tool support frequently misses spots like this.

With intersection types, the typical mistake is forgetting the parentheses in DNF contexts, or trying to place a standalone type inside an intersection group, for example (Countable&false), which the engine rejects as an error, since false is not an object type and can never be satisfied together with an interface. The table below compares unsafe or imprecise patterns with the respective recommended declarations.

Task Unsafe / Imprecise Recommended Declaration Benefit
Accepting multiple types @param int|string $id (PHPDoc only) int|string $id Runtime check by the engine
Declaring an optional return value mixed as return type Foo|null Precise type information for IDE and analyzers
Requiring several interfaces CountableIterator as a new interface Countable&Iterator No extra type needed
Combining union and intersection Countable&Iterator|string (syntax error) (Countable&Iterator)|string Correct DNF syntax since PHP 8.2
Legacy function with guaranteed false bool as return type false as standalone type Clearly signals: never true
Type check after a union parameter gettype() string comparison match(true) with instanceof/is_* Type narrowing for static analysis

9. Comparison to PHPDoc-only Types and Migrating Existing Signatures

Many existing codebases from the PHP 7 era document union-like signatures exclusively through PHPDoc: /** @param int|string $id */ function setId($id). This statement is completely ignored by the engine at runtime. Only a static analysis tool such as PHPStan reads the comment and compares it against actual usage. If the tool is missing from the build process, or a warning gets overlooked, the gap between documentation and reality goes undetected until an error surfaces in production.

Migrating to native union and intersection types should happen gradually: simple, non-generic signatures should be converted to native types first, since these give the engine immediate runtime protection. More complex generic cases, such as a collection with a value type parameter, remain for now with a combined solution of a native base type declaration plus a supplementary PHPDoc template, since PHP does not support native generics.

A sensible intermediate step for large legacy projects is a PHPStan baseline that tolerates existing PHPDoc-only spots for now, while new code is required to use native union and intersection types. This lets the share of natively typed code grow continuously, without needing a single large migration sprint that, in practice, rarely gets completed in full.

10. Summary

Union types and intersection types together solve a problem that PHPDoc comments had only addressed as a workaround: precise, engine-checked type declarations for parameters, properties, and return values, verified at runtime. Since PHP 8.0, the engine enforces such declarations as int|string with a TypeError on violation, and since PHP 8.1, matching interface combinations such as Countable&Iterator add the ability to combine several interface requirements without an additional composite interface.

PHP 8.2 closed the remaining gaps with DNF types and standalone types: (A&B)|C combines both concepts in one expression, while false and true as dedicated return types document guaranteed behavior across class hierarchies. Anyone who consistently uses these constructs instead of broad mixed signatures, and adds runtime type checks with match and instanceof, benefits from better IDE completion and more reliable static analysis through PHPStan and Psalm.

Union Types and Intersection Types in PHP 8.4: The Essentials at a Glance

Union Types

int|string requires exactly one of the given types. Use ?Type only for a single non-null type, otherwise the full |null syntax.

Intersection Types

Countable&Iterator requires all given types at once, usually for interfaces, without a new composite interface.

DNF Types (PHP 8.2)

(A&B)|C combines union and intersection. Intersection groups must be parenthesized, no standalone types inside them.

Standalone Types

false and true as a dedicated return type for guaranteed behavior across class hierarchies.

11. FAQ: Union Types and Intersection Types in PHP 8.4

1Union types vs. intersection types?
The former requires exactly one of the given types, the latter requires all of them at once, usually for interfaces.
2Since when do these types exist?
Since PHP 8.0 and 8.1 respectively, DNF and standalone types arrived with PHP 8.2.
3Declaring a nullable union type correctly?
For one type, ?Type is enough. For several types alongside null, Type1|Type2|null is required.
4Intersection types with scalar types?
Not possible, since no value can satisfy two scalar types at once.
5What is a DNF type?
A combination of union and intersection, where intersection groups must be parenthesized, for example (Countable&Iterator)|null.
6What does standalone type false mean?
Signals a guaranteed false return value, useful in base classes without support for a feature.
7Automatic type narrowing at runtime?
No, explicit checks with is_int(), is_string(), or instanceof plus match(true) are needed.
8How do PHPStan and Psalm help?
Flow analysis narrows the type after a guard automatically for the rest of the code block, which the engine itself does not do.
9Most common mistake with union types?
Overly broad unions such as array|string|int|bool that mask a missing domain model and prevent narrowing.
10Replace PHPDoc-only types right away?
Simple cases yes, gradually through a baseline. Generic cases still need PHPDoc templates.

Mironsoft

PHP 8.4 development, type safety, and code quality for Magento and PHP projects

Want a type-safe PHP codebase for your next project?

We review existing PHP code, replace broad mixed-style signatures with precise, combinable type declarations, and set up PHPStan and Psalm at the highest sensible level, so your type system catches errors before they reach production.

Code Review

Reviewing existing signatures for overly broad type declarations and unused interface combinations

Type Migration

Gradual migration from PHPDoc-only types to native type declarations

Static Analysis

PHPStan and Psalm setup including a baseline strategy for legacy code