PHP 8.2 — DNF types (Disjunctive Normal Form)
Concept
Disjunctive Normal Form (DNF) types, introduced in PHP 8.2, allow union types to contain intersection types as elements: (Countable&Traversable)|null. Before PHP 8.2, you could use union types (A|B) or intersection types (A&B) but not both in the same declaration. DNF types unlock the full expressiveness needed to type-hint complex generics in a world without generics — for example, "a collection that is both Countable and Iterable, or null."
The naming comes from Boolean algebra: a formula is in Disjunctive Normal Form when it is an OR of ANDs. In PHP's type system, that means a union (OR) of intersection groups (AND). Each intersection must be enclosed in parentheses: (A&B)|(C&D) reads as "a value that is both A and B, or both C and D." A plain class or interface in the union does not need parentheses: string|(Countable&Traversable).
DNF types appear most naturally in generic-like code where you have collections. Before PHP has full generics, (Countable&Traversable) is the closest approximation of "an iterable collection with a known length." Framework infrastructure — specifically PSR-compliant container implementations, Laravel's collection pipelines, and Symfony's type-resolved argument resolvers — benefits from this precision. PHPStan and Psalm also use DNF internally for type inference, so writing DNF in user code aligns with the analyzer's internal model.
One practical limitation: PHP's runtime does not fully validate intersection constraints inside unions. A (Countable&Traversable)|null parameter accepts null or any value that implements both interfaces; PHP checks this at call time. However, static analyzers can refine the type much further, narrowing inside if ($value instanceof Countable) branches.
| Type syntax | PHP version | Example |
|---|---|---|
| Union | 8.0+ | int|string |
| Intersection | 8.1+ | Countable&Traversable |
| DNF (union of intersections) | 8.2+ | (Countable&Traversable)|null |
| Nested DNF | 8.2+ | (A&B)|(C&D) |
Code Example
<?php
declare(strict_types=1);
interface Repository {}
interface Cacheable {}
interface Loggable {}
// A function that accepts either:
// - an object that is both Repository AND Cacheable, OR
// - an object that is both Repository AND Loggable, OR
// - null (no implementation available)
function resolve((Repository&Cacheable)|(Repository&Loggable)|null $impl): string
{
if ($impl === null) {
return 'no implementation';
}
if ($impl instanceof Cacheable) {
return 'cacheable repository';
}
return 'loggable repository';
}
// Concrete classes
class CacheableRepo implements Repository, Cacheable {}
class LoggableRepo implements Repository, Loggable {}
class PlainRepo implements Repository {} // does NOT satisfy either DNF branch
echo resolve(new CacheableRepo()); // cacheable repository
echo resolve(new LoggableRepo()); // loggable repository
echo resolve(null); // no implementation
// resolve(new PlainRepo()); // TypeError at runtime
// DNF in return types
interface Collection extends \Countable, \Traversable {}
function loadCollection(bool $required): (Collection&\ArrayAccess)|null
{
if (!$required) {
return null;
}
// ArrayCollection implements Collection and ArrayAccess
return new class implements Collection, \ArrayAccess, \Countable, \Traversable
{
private array $items = [];
public function count(): int { return count($this->items); }
public function offsetExists(mixed $offset): bool { return isset($this->items[$offset]); }
public function offsetGet(mixed $offset): mixed { return $this->items[$offset] ?? null; }
public function offsetSet(mixed $offset, mixed $value): void { $this->items[$offset] = $value; }
public function offsetUnset(mixed $offset): void { unset($this->items[$offset]); }
public function getIterator(): \Traversable { return new \ArrayIterator($this->items); }
};
}
$col = loadCollection(true);
if ($col !== null) {
echo count($col); // Countable branch
$col['key'] = 'value'; // ArrayAccess branch
}Interview Q&A
Q: What is Disjunctive Normal Form in PHP's type system and when would you actually use it?
DNF types let you write a union where some members are intersection types — (A&B)|C. Without DNF, PHP 8.1 forced you to choose either intersection or union, not both. You reach for DNF when a parameter legitimately accepts two different composite types: for example, a caching layer that accepts either a (PsrCache&Countable) cache pool with size semantics, or a plain null to disable caching. Without DNF you'd fall back to mixed or repeat the type assertion manually inside the function, losing static analysis precision. It's a relatively rare feature in application code but becomes important in framework infrastructure and library APIs.
Q: How do static analyzers like PHPStan interact with DNF types when narrowing inside branches?
PHPStan treats DNF types as explicit union nodes in its type lattice. When you enter an if ($value instanceof Cacheable) branch on a (Countable&Traversable)|(Cacheable&Loggable)|null parameter, PHPStan narrows the type to the intersection member that satisfies instanceof Cacheable, which is (Cacheable&Loggable). The other branches are eliminated. This means you get accurate auto-completion and type checks within each conditional arm without any explicit casting. The precision matches what you'd get from generics in a language like Java or TypeScript's discriminated unions.
Q: What are the parsing rules and valid forms for DNF types in PHP 8.2?
Each intersection group must be parenthesized when it appears inside a union: (A&B)|C is valid; A&B|C is a parse error. A standalone intersection without a union does not need parens: A&B is still valid. Nesting beyond one level — ((A&B)&C) or ((A|B)&C) — is not valid syntax in PHP 8.2; the grammar only allows flat intersections inside union alternatives. Union members that are plain types (classes, interfaces, null, true, false, int, etc.) do not need parentheses. Redundant or provably impossible DNF forms — like (A&B)|(A&C) where B and C are final classes with no common subtype — may trigger static analysis warnings but are not syntax errors.