Union types (int|string) and intersection types (A&B)
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 feature | PHP version | Operator | Use case |
|---|---|---|---|
| Union types | 8.0 | | | Accept multiple distinct types |
mixed | 8.0 | — | Explicit "any" type |
never | 8.1 | — | Functions that always throw/exit |
| Intersection types | 8.1 | & | Require multiple interface contracts |
| DNF types | 8.2 | (&)| | Combinations of intersections + unions |
Code Example
<?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.