0

Union types (int|string) and intersection types (A&B)

Intermediate5 min read·php-02-009
interview

Concept

PHP 8.0 introduced union types, which let a parameter, property, or return value declare that it accepts one of several named types. The syntax is TypeA|TypeB, e.g. int|string. Before union types, developers had to either leave parameters untyped (losing static analysis benefits) or create separate overloaded methods. Union types are resolved at runtime by PHP's internal type checker: if strict_types=1 is active, the value must already be one of the declared types; in coercive mode, PHP attempts to coerce the value.

Intersection types, added in PHP 8.1, serve the opposite purpose: a value must satisfy all the listed types simultaneously. The syntax uses &, e.g. Countable&Iterator. Intersection types are only meaningful with named types (classes and interfaces) — you cannot write int&string because a value cannot be both primitives at once. They are particularly useful when a function requires an object that implements multiple interfaces without needing to create a new composed interface just for that constraint.

PHP 8.2 added Disjunctive Normal Form (DNF) types, which combine union and intersection types: (Countable&Iterator)|null. Without DNF, you could not mix & and | in a single type declaration. This enables expressing "either a concrete implementation of multiple contracts, or null" — a common pattern in optional dependencies.

The never type (PHP 8.1) is a special bottom type for functions that never return normally: they always throw an exception or call exit(). Static analysers use never to infer that code after a never-typed call is unreachable. The void type means a function returns without a value (calling return; is allowed, return $value; is not). The mixed type is the explicit opt-out from typing — it accepts any value including null and tells the engine (and static analysers) to make no assumptions.

A key gotcha with union types: the order of types in a union declaration does not matter for type checking, but it does affect how Psalm and PHPStan report types when narrowing. Another gotcha: int|float is technically valid but can be simplified to float in coercive mode since PHP coerces int to float. For strict mode, int|float is correct if you want to accept both without coercion.

Type featurePHP versionOperatorUse case
Union types8.0|Accept multiple distinct types
mixed8.0Explicit "any" type
never8.1Functions that always throw/exit
Intersection types8.1&Require multiple interface contracts
DNF types8.2(&)|Combinations of intersections + unions

Code Example

php
<?php
declare(strict_types=1);

// Basic union type
function formatId(int|string $id): string
{
    return is_int($id) ? sprintf('%08d', $id) : strtoupper($id);
}

echo formatId(42);      // '00000042'
echo formatId('abc');   // 'ABC'

// Union with null (equivalent to ?string but more explicit)
function findRecord(int $id): array|null  // same as ?array
{
    return $id > 0 ? ['id' => $id] : null;
}

// Intersection type — object must implement both interfaces
interface Serializable2 {
    public function serialize(): string;
}
interface Loggable {
    public function toLogArray(): array;
}

function persist(Serializable2&Loggable $entity): void
{
    $data = $entity->serialize();
    $log  = $entity->toLogArray();
    // Both methods guaranteed available
}

// DNF type (PHP 8.2+)
interface HasId {
    public function getId(): int;
}
interface HasSlug {
    public function getSlug(): string;
}

function resolveRoute((HasId&HasSlug)|null $resource): string
{
    if ($resource === null) {
        return '/404';
    }
    return '/' . $resource->getSlug() . '-' . $resource->getId();
}

// never return type — signals to static analysis this path never returns
function abort(int $code, string $message): never
{
    throw new \RuntimeException("HTTP $code: $message");
}

function getUser(int $id): array
{
    $user = findRecord($id);
    if ($user === null) {
        abort(404, 'User not found');  // analyser knows execution stops here
    }
    return $user;  // $user is array here — narrowed by static analysis
}

// mixed — explicit "I accept anything"
function debugDump(mixed $value): void
{
    var_dump($value);
}

// Narrowing union types with instanceof and is_* checks
function processInput(int|array|null $input): string
{
    if ($input === null) {
        return 'nothing';
    }
    if (is_array($input)) {
        return implode(',', $input);
    }
    return (string) $input;  // $input is int here
}

Interview Q&A

Q: What is the difference between a union type TypeA|TypeB and an intersection type TypeA&TypeB, and can you give a real production example where each is the right choice?

A union type means "the value is one of these types" — the type checker accepts a value that matches any single member. An intersection type means "the value must satisfy every type simultaneously" — only an object that implements all the listed interfaces passes. A real union example: a repository method find(int|string $id) that accepts either an integer primary key or a UUID string. A real intersection example: a queue worker method dispatch(Queueable&ShouldBeEncrypted $job) that requires a job implementing both contracts — you want to guarantee the caller passes an object with both handle() and encrypt() behaviour, without creating a combined QueueableAndEncryptable interface solely for this constraint. Intersection types are interface composition without new interface declarations.


Q: Why does PHP's never type matter for static analysis beyond being a documentation hint?

never is a bottom type in type theory: it is a subtype of every other type. When a static analyser sees a call to a never-typed function, it infers that the code path following that call is unreachable. This eliminates false-positive "possibly undefined variable" errors in patterns like $user = findOrFail($id); // throws if null, so $user is always non-null below. Psalm and PHPStan both use never to propagate narrowing — if a branch ends with a never call, the analyser discards that branch for subsequent analysis. Without never, tools could not distinguish between a function that sometimes returns and one that always throws, leading to union types that include null unnecessarily or requiring explicit @psalm-return never annotations.


Q: How do DNF types in PHP 8.2 enable patterns that were impossible with pure union or intersection types alone?

DNF (Disjunctive Normal Form) types follow the logical form (A&B)|(C&D)|E, normalised so intersections are grouped in parentheses. Before PHP 8.2, you could write A|B or A&B but mixing them with a single | in one declaration was a parse error. The motivating example is an optional interface-constrained parameter: function process((Countable&Iterator)|null $items). You cannot express this with pure intersection (Countable&Iterator never includes null) or pure union (you cannot put the intersection constraint inside a union without DNF). This matters for dependency injection where some parameters are optional complex contracts, and for nullable collections that must be iterable and sizeable simultaneously.