Union, Intersection, and DNF Types
PHP type declarations are executable contracts. They restrict what can enter a function, what a method may return, and what can be stored in a typed property. A useful declaration communicates the real set of valid values without making callers guess or forcing the implementation to inspect arbitrary data.
Composite types describe contracts that a single type name cannot express. A union offers alternatives, an intersection requires several object contracts at once, and a DNF type combines those ideas. PHP added union types in 8.0, intersection types in 8.1, and disjunctive normal form types in 8.2. That version history matters when a package supports older PHP releases.
Union Types Express Alternatives
A union type uses | and means that a value may satisfy any one of the listed types.
<?php
declare(strict_types=1);
function formatIdentifier(int|string $id): string
{
return 'ID-' . (string) $id;
}
echo formatIdentifier(42) . PHP_EOL;
echo formatIdentifier('A100') . PHP_EOL;
// Prints:
// ID-42
// ID-A100
int|string is more informative than mixed. It excludes arrays, objects, booleans, and null because none belongs to this API. Inside the function, the implementation knows the value has one of two supported forms.
A union should represent genuine domain alternatives. It should not be a list of every type that happened to reach the function while bugs were being fixed. A declaration such as string|int|array|object|null often means the boundary has not parsed its input into a stable application model.
Nullable Types Are Small Unions
User|null means a function returns either a User or no user. For one named type, ?User is equivalent shorthand.
<?php
declare(strict_types=1);
final class User
{
public function __construct(
public int $id,
public string $email,
) {
}
}
function findUser(int $id): User|null
{
return $id === 7
? new User(7, 'ada@example.com')
: null;
}
$user = findUser(7);
echo $user?->email ?? 'not found';
echo PHP_EOL;
// Prints:
// ada@example.com
Do not combine the forms as ?User|null; that repeats the same alternative. The ?T syntax works only for a single base type. A declaration with several alternatives uses the full union form, such as User|Guest|null.
A nullable return is appropriate when absence is an expected outcome. If failure needs a reason, recovery information, or several distinct states, an exception or explicit result object may communicate more than null.
Literal false, true, and null
Some PHP APIs historically return a useful value or false on failure. A union can describe that contract directly.
<?php
declare(strict_types=1);
function firstHeader(array $headers): string|false
{
$value = reset($headers);
return is_string($value) ? $value : false;
}
$header = firstHeader([]);
echo $header === false ? 'no header' : $header;
echo PHP_EOL;
// Prints:
// no header
Use === false, because a valid string such as '0' is falsey but is not the failure value. For new domain APIs, compare false, null, exceptions, and result objects deliberately rather than copying the shape of an older built-in function.
PHP 8.2 allows false, true, and null as standalone declaration types. false|true is not legal; use bool. A union containing bool|false is also redundant and rejected.
Intersection Types Require Every Capability
An intersection uses & and means that one object must satisfy every listed class or interface type.
<?php
declare(strict_types=1);
interface CanRender
{
public function render(): string;
}
interface HasCacheKey
{
public function cacheKey(): string;
}
final class ProductCard implements CanRender, HasCacheKey
{
public function render(): string
{
return '<article>Keyboard</article>';
}
public function cacheKey(): string
{
return 'product-card:keyboard';
}
}
function cacheRendered(CanRender&HasCacheKey $component): string
{
return $component->cacheKey() . ' => ' . $component->render();
}
echo cacheRendered(new ProductCard()) . PHP_EOL;
// Prints:
// product-card:keyboard => <article>Keyboard</article>
The function does not need a particular concrete class. It needs an object with two capabilities. A renderer without a cache key is insufficient, as is a cache-key provider that cannot render.
Intersection members must be class or interface types. Scalar intersections such as string&Countable are illegal. PHP also rejects self, parent, and static inside intersections. When several methods repeatedly require the same capability combination, a dedicated interface that extends the smaller interfaces may be clearer than repeating a long intersection everywhere.
DNF Types Combine Alternatives And Requirements
DNF means disjunctive normal form. In PHP, a DNF declaration is a union in which an alternative may be a parenthesized intersection.
<?php
declare(strict_types=1);
interface CanExport
{
public function export(): string;
}
interface HasFilename
{
public function filename(): string;
}
final class CsvReport implements CanExport, HasFilename
{
public function export(): string
{
return 'id,total';
}
public function filename(): string
{
return 'orders.csv';
}
}
final class InlineReport
{
public function __construct(public string $body)
{
}
}
function sendReport((CanExport&HasFilename)|InlineReport $report): string
{
if ($report instanceof InlineReport) {
return 'inline: ' . $report->body;
}
return 'attachment: ' . $report->filename() . ' contains ' . $report->export();
}
echo sendReport(new CsvReport()) . PHP_EOL;
echo sendReport(new InlineReport('No orders today')) . PHP_EOL;
// Prints:
// attachment: orders.csv contains id,total
// inline: No orders today
The declaration means either an object that is both exportable and filename-aware, or one InlineReport. Parentheses around the intersection are required. PHP supports this normalized form; it does not allow arbitrary mixtures designed as boolean-type puzzles.
DNF types are useful when the combination is a real public contract. If a signature takes several lines to understand, consider introducing a named interface or redesigning the operation. A type can be legal and still be too complicated for the API it serves.
Duplicate And Redundant Members Are Rejected
PHP detects many invalid composite declarations at compile time. These include repeated names such as int|string|INT, bool|false, object|Product, and iterable|array. mixed already includes every value, so adding it to a union is meaningless. never is a return-only bottom type and cannot be one member of a union.
For intersections, every member must be an object class type. In DNF declarations, PHP also rejects combinations where one alternative obviously makes another redundant.
The check does not load every class to prove the mathematically smallest type. If Child extends Parent, PHP can still accept Parent|Child even though Parent covers both. That declaration remains poor communication and should be simplified by the developer.
Types Narrow The Implementation
Composite declarations become valuable when the implementation narrows them safely.
<?php
declare(strict_types=1);
final class Percentage
{
public function __construct(public int|float $value)
{
if ($value < 0 || $value > 100) {
throw new InvalidArgumentException('Percentage must be from 0 to 100.');
}
}
}
function displayPercentage(Percentage|string $input): string
{
if (is_string($input)) {
return 'label: ' . $input;
}
return 'value: ' . $input->value . '%';
}
echo displayPercentage(new Percentage(12.5)) . PHP_EOL;
// Prints:
// value: 12.5%
Use is_int(), is_string(), and similar checks for scalar alternatives, and instanceof for object alternatives. After a branch establishes one case, static-analysis tools can usually infer the narrower type too.
The type declaration does not replace value validation. int|float establishes the representation of a percentage, but 500 still has an allowed PHP type. The constructor enforces the domain range separately.
Strict Types And Scalar Unions
declare(strict_types=1) affects scalar argument coercion at the calling file. Without strict typing, PHP may convert a compatible scalar before checking a declaration. Under strict typing, a string is not accepted for int|float; an integer is still accepted for float as PHP's documented exception.
Object alternatives do not become interchangeable through scalar coercion. An object either satisfies the class or interface declaration or it does not.
Remember that strictness is controlled by the file making the call, not merely the file where the function was declared. Public libraries should not use strict_types as a substitute for validating text received from HTTP requests, environment variables, CSV files, or command-line arguments. Parse those external strings at the boundary.
Composite Types In Inheritance
Method overrides must remain compatible with parent classes and interfaces. Parameter types are contravariant: an implementation may accept a broader set of inputs, but must not reject inputs promised by the parent contract. Return types are covariant: an implementation may return a narrower type, but must not add possibilities callers were not promised.
For example, if an interface promises find(): User|null, an implementation may narrow that to User only if it genuinely always finds a user. It cannot widen the return to User|string|null. Similarly, an implementation cannot replace an accepted int|string parameter with only int, because callers are entitled to pass strings through the interface.
Changing a public union can therefore be a backward-compatibility change. Removing an accepted parameter alternative breaks callers. Adding a return alternative breaks callers that handled the previous exhaustive set. Treat type changes as API design, not cosmetic annotation.
Native Types And Static-Analysis Detail
Native PHP types enforce runtime categories, but they cannot describe every useful structure. PHP cannot natively declare array<string, User> or a non-empty list. Psalm and PHPStan can add those details through documented annotations and inference while native declarations protect the broad runtime boundary.
Use both layers consistently. A native array plus an accurate static-analysis shape is stronger than replacing the native declaration with mixed. Do not write an annotation that contradicts the executable signature.
Composite types are also different from generics. Repository|Cache means one of two unrelated runtime categories. A generic concept such as Repository<User> describes the kind of value a repository contains, which currently belongs to static analysis rather than PHP's native generic syntax.
When A Union Is Hiding Several Operations
Suppose save(string|array|User $value) handles a username, a payload, and an entity through three separate branches. The declaration is valid, but the method probably combines parsing, construction, and persistence. Separate methods or a boundary mapper can produce a smaller, clearer core API.
Another warning sign is behavior controlled by both a broad union and flags: process(string|array $input, bool $validate, bool $persist). Named command objects or dedicated services make the valid combinations explicit and prevent impossible states.
Choose the narrowest honest declaration, not the shortest declaration and not the most permissive one. A single type is best when one type is valid. A union is best for a small set of alternatives. An intersection is best when the same object must provide several capabilities. DNF is best when alternatives include a genuine capability combination.
What You Should Be Able To Do
After this lesson, you should be able to read A|B, A&B, and (A&B)|C without ambiguity; identify which PHP release introduced each form; and explain why parentheses are required around intersections inside DNF declarations.
You should also be able to spot redundant members, distinguish representation checks from business validation, preserve method compatibility across inheritance, and decide when a named interface or separate operation communicates more clearly than another member added to a union.
Practice
Practice: Model Allowed Report Inputs
Create a small report-sending example that uses union and intersection types deliberately.
Task
Build:
- a
CanExportinterface with anexport(): stringmethod - a
HasFilenameinterface with afilename(): stringmethod - a
CsvReportclass that implements both interfaces - an
InlineReportclass that carries a body string - a
sendReport()function that accepts(CanExport&HasFilename)|InlineReport
Use strict types. Show one attachment-style report and one inline report. Keep the expected output inside the PHP code block as printed lines or comments.
Afterward, add a short note explaining why this is better than accepting mixed.
Show solution
This solution uses an intersection for reports that must be exportable and named, then a union to allow a separate inline report shape.
<?php
declare(strict_types=1);
interface CanExport
{
public function export(): string;
}
interface HasFilename
{
public function filename(): string;
}
final class CsvReport implements CanExport, HasFilename
{
public function __construct(
private string $filename,
private string $contents,
) {
}
public function export(): string
{
return $this->contents;
}
public function filename(): string
{
return $this->filename;
}
}
final class InlineReport
{
public function __construct(
public string $body,
) {
}
}
function sendReport((CanExport&HasFilename)|InlineReport $report): string
{
if ($report instanceof InlineReport) {
return 'inline: ' . $report->body;
}
return 'attachment: ' . $report->filename() . ' contains ' . $report->export();
}
echo sendReport(new CsvReport('orders.csv', 'id,total')) . PHP_EOL;
echo sendReport(new InlineReport('No orders today')) . PHP_EOL;
// Prints:
// attachment: orders.csv contains id,total
// inline: No orders today
This is better than mixed because the function signature tells the next developer exactly which shapes are valid. PHP will reject unrelated values before the function tries to use methods that may not exist.
Practice: Require Two Capabilities
Design a function that accepts any object able to produce an invoice body and a stable storage key.
Task
Create:
- a
RendersInvoiceinterface withrenderInvoice(): string - a
HasStorageKeyinterface withstorageKey(): string - an
HtmlInvoiceclass implementing both interfaces - a
PreviewInvoiceclass implementing onlyRendersInvoice - a
storeInvoice()function acceptingRendersInvoice&HasStorageKey
Call storeInvoice() with HtmlInvoice and print the key and rendered body. Leave the invalid PreviewInvoice call as a comment and explain why PHP would reject it before the function body runs.
Check Your Work
Confirm that the parameter requires one object satisfying both interfaces. Do not replace the intersection with a concrete HtmlInvoice type, because the function should remain open to other implementations with the same capabilities.
Show solution
The intersection declares the two behaviors the storage operation actually uses.
<?php
declare(strict_types=1);
interface RendersInvoice
{
public function renderInvoice(): string;
}
interface HasStorageKey
{
public function storageKey(): string;
}
final class HtmlInvoice implements RendersInvoice, HasStorageKey
{
public function __construct(private int $number)
{
}
public function renderInvoice(): string
{
return '<h1>Invoice ' . $this->number . '</h1>';
}
public function storageKey(): string
{
return 'invoice-' . $this->number . '.html';
}
}
final class PreviewInvoice implements RendersInvoice
{
public function renderInvoice(): string
{
return '<h1>Preview</h1>';
}
}
function storeInvoice(RendersInvoice&HasStorageKey $invoice): string
{
return $invoice->storageKey() . ' => ' . $invoice->renderInvoice();
}
echo storeInvoice(new HtmlInvoice(1042)) . PHP_EOL;
// storeInvoice(new PreviewInvoice()); // TypeError
// Prints:
// invoice-1042.html => <h1>Invoice 1042</h1>
PreviewInvoice provides rendering but not a stable key, so it does not satisfy the complete intersection. The function remains independent of the concrete HtmlInvoice class.
Practice: Replace An Over-Broad Union
Refactor an import API whose type declaration reveals that it handles too many input stages.
Starting Point
function importCustomer(string|array|Customer $input): Customer
The string is raw JSON, the array is decoded data, and the Customer is already a domain object.
Task
Create:
- a
Customerclass with an email property - a
CustomerPayloadclass that validates and constructs itself from an array - a
decodeCustomerJson(string $json): CustomerPayloadboundary function - a
createCustomer(CustomerPayload $payload): Customerapplication function
Use json_decode(..., true, flags: JSON_THROW_ON_ERROR) and reject a missing or non-string email. Demonstrate the flow with one JSON document.
Explain why the refactored core function no longer needs a union and which responsibility moved to the boundary.
Show solution
The boundary parses untrusted text before the application function receives a validated payload.
<?php
declare(strict_types=1);
final class Customer
{
public function __construct(public string $email)
{
}
}
final class CustomerPayload
{
private function __construct(public string $email)
{
}
public static function fromArray(array $data): self
{
if (!isset($data['email']) || !is_string($data['email'])) {
throw new InvalidArgumentException('A string email is required.');
}
return new self($data['email']);
}
}
function decodeCustomerJson(string $json): CustomerPayload
{
$data = json_decode(
$json,
true,
flags: JSON_THROW_ON_ERROR,
);
if (!is_array($data)) {
throw new InvalidArgumentException('Customer JSON must contain an object.');
}
return CustomerPayload::fromArray($data);
}
function createCustomer(CustomerPayload $payload): Customer
{
return new Customer($payload->email);
}
$payload = decodeCustomerJson('{"email":"ada@example.com"}');
$customer = createCustomer($payload);
echo $customer->email . PHP_EOL;
// Prints:
// ada@example.com
The original union mixed raw text, decoded transport data, and an existing domain object. Parsing and shape validation now happen at the boundary, while createCustomer() has one stable input contract.