0

Intersection types and DNF types (PHP 8.2+)

Advanced5 min read·php-08-004

Concept

PHP 8.0 introduced union types (int|string), allowing a parameter or return value to be one of several types. PHP 8.1 extended this with intersection types (Countable&Iterator), requiring a value to satisfy all listed types simultaneously — useful when a function needs an argument that implements multiple interfaces at once. PHP 8.2 combined both into Disjunctive Normal Form (DNF) types, which mix intersections and unions: (Countable&Traversable)|null is a valid DNF type. The name comes from boolean algebra — every DNF expression is a union (OR) of intersection (AND) groups.

The rules for DNF types: each atom in a union can itself be an intersection group wrapped in parentheses. Pure-intersection groups (A&B) are allowed without parentheses as a special case. The parser enforces that intersections appear inside union arms — you cannot write A&B|C&D (ambiguous), but you can write (A&B)|(C&D). The null shorthand (?T) expands to T|null and is syntactic sugar only for that specific case; for complex types you must write out the union explicitly.

Type system featurePHP versionSyntax example
Union types8.0int|string|null
Intersection types8.1Iterator&Countable
DNF types8.2(Iterator&Countable)|null
never return type8.1function throws(): never
true/false types8.2function isValid(): true
null standalone type8.2function noop(): null

Intersection types are particularly valuable in library and framework code. Laravel's Illuminate\Contracts\Container\Container methods often accept arguments that must be both stringable and identifiable. PHPUnit's assertCount() accepts Countable&Traversable. By expressing these constraints in the type signature rather than runtime assertions inside the function body, IDEs and static analysers (PHPStan, Psalm) can catch misuse at development time rather than production time.

A performance note: PHP's type checking for intersection types happens at the engine level and is not significantly slower than union type checking. The cost is proportional to the number of types checked, and in practice both are negligible compared to actual computation. The bigger benefit is that stricter types reduce defensive instanceof checks inside functions, which actually removes runtime instructions.

Code Example

php
<?php
declare(strict_types=1);

interface Serializable
{
    public function serialize(): string;
}

interface Loggable
{
    public function toLogContext(): array;
}

interface Paginatable
{
    public function getPage(): int;
    public function getPerPage(): int;
}

// PHP 8.1 intersection type: argument must implement BOTH interfaces
function logAndSerialize(Serializable&Loggable $entity): array
{
    return [
        'serialized' => $entity->serialize(),
        'context'    => $entity->toLogContext(),
    ];
}

// PHP 8.2 DNF type: (Serializable AND Loggable) OR null
function maybeLogAndSerialize((Serializable&Loggable)|null $entity): ?array
{
    if ($entity === null) {
        return null;
    }
    return logAndSerialize($entity);
}

// PHP 8.2 DNF with multiple intersection arms:
// The collection must be (Serializable AND Loggable) OR (Paginatable AND Countable)
function processCollection(
    (Serializable&Loggable)|(Paginatable&\Countable) $items
): string {
    if ($items instanceof Serializable && $items instanceof Loggable) {
        return $items->serialize();
    }
    // Must be Paginatable & Countable
    return "Page {$items->getPage()} of " . count($items) . " items";
}

// Concrete class satisfying the intersection
final class UserEvent implements Serializable, Loggable
{
    public function __construct(
        private readonly string $userId,
        private readonly string $eventType,
    ) {}

    public function serialize(): string
    {
        return json_encode(['user' => $this->userId, 'event' => $this->eventType]);
    }

    public function toLogContext(): array
    {
        return ['user_id' => $this->userId, 'type' => $this->eventType];
    }
}

$event = new UserEvent('u-123', 'login');
$result = logAndSerialize($event);
print_r($result);

// This would cause a TypeError — stdClass satisfies neither interface
// logAndSerialize(new \stdClass());

// Return type as intersection — the function guarantees the returned value
// implements both interfaces
function createEvent(string $userId, string $type): Serializable&Loggable
{
    return new UserEvent($userId, $type);
}

$ev = createEvent('u-456', 'purchase');
echo $ev->serialize();

Interview Q&A

Q: What is the difference between a union type and an intersection type, and when is each appropriate?

A union type (A|B) means the value can be either A or B — the function accepts values of different shapes and typically uses instanceof or is_* checks inside to branch behaviour. An intersection type (A&B) means the value must be both A and B simultaneously — the function relies on methods from all listed interfaces without needing to branch. Union types are appropriate for flexible APIs (accept string or array input), while intersection types are appropriate for constraints that combine capabilities (a logging repository that must be both Countable and Iterable). In static analysis, intersection types narrow the type space rather than expanding it, which allows the analyser to guarantee that calls to methods from all intersected interfaces are safe.


Q: How does DNF typing interact with PHP's type coercion in non-strict mode?

In non-strict mode (declare(strict_types=0)), PHP will attempt scalar coercions before checking union arms. For int|string, a float like 3.7 would be coerced to int 3. With DNF types involving object intersection groups, coercion is not attempted — an object either satisfies the intersection or it does not. The complex part is when a DNF type mixes scalars and intersections: (Iterator&Countable)|int. PHP first checks if the value satisfies the intersection (no coercion); if not, it checks if it can be coerced to int. In strict_types=1 mode coercions do not occur and the value must match one arm exactly. Always use declare(strict_types=1) in new code — it makes type errors deterministic and prevents subtle coercion bugs.


Q: What does PHPStan or Psalm gain from intersection types that it cannot infer from runtime instanceof checks?

Static analysers track types through the entire call graph. When a parameter is declared as Countable&Iterator, the analyser knows without running the code that both count() and iteration are valid on that parameter — it does not need to see an instanceof guard inside the function body. This has downstream benefits: the analyser can also verify that callers pass objects satisfying both constraints, surfacing errors at the call site rather than inside the function. With runtime instanceof checks, the analyser would widen the parameter type to mixed or object, losing all type information until after the check. Intersection types in signatures preserve the full type context throughout, enabling accurate method autocompletion in IDEs and catching interface contract violations without running the application.