PHP 8.1: Intersection Types

Version8.1
TypeNew Feature

PHP 8.1 supports Intersection Types, which allows declaring a type for a parameter, property, or return types and enforce that values belong to all of the declared class/interface types. This is the opposite of Union Types, which allows any of the declared types. PHP 8.1's implementation of Intersection Types is called "pure" Intersection Types because combining Union Types and Intersection Types in the same declaration is not allowed.

Intersection Types are declared by combining the class/interface names with an & sign.

For a use-case example, consider PHP's built-in Iterator and Countable interfaces. The Iterator interface makes it possible to iterate the class object using foreach. However, unless the same class implements Countable interface, it is not possible to call the count() function on such objects.

With an Intersection Type, it is now possible to type-check class objects to implement both Iterator and Countable interfaces.

function count_and_iterate(Iterator&\Countable $value) {
    foreach($value as $val) {}
    count($value);
}

In the snippet above, the $value parameter must be an object from class that implements both Iterator and Countable interfaces. Passing any other value causes a type error.

class CountableIterator implements \Iterator, \Countable {
    public function current(): mixed {}
    public function key(): mixed {}
    public function next(): void {}
    public function rewind(): void {}
    public function valid(): bool {}

    public function count(): int {}
}

Because CountableIterator class implements both Iterator and Countable interfaces, instances of this class meet the type requirement Iterator&Countable.

count_and_iterate(new CountableIterator());

However, passing any class that does not implement both Iterator and Countable interfaces results in a Type Error:

count_and_iterate(new stdClass());
Fatal error: Uncaught TypeError: count_and_iterate(): Argument #1 ($value) must be of type Iterator&Countable, stdClass given

Pure Intersection Types

The initial implementation of Intersection Types only allow pure intersection types; composite types with nullable or Union Types are not allowed, and results in a syntax error.

Class and Interface names only

Intersection Types only support class and interface names as intersection members. Scalar types, array, void, mixed, callable, never, iterable, null, and other types are not allowed.

function foo(string|int $val) {}

The Intersection Type in this snippet is not allowed, and results in a fatal error at compile-time.

Fatal error: Type string cannot be part of an intersection type in ... on line ...

Further, static, parent, and self cannot be used in an Intersection Type.

Duplicate Members in an Intersection Type

Intersection Types are checked at compile-time without triggering any class autoloading. If a name-resolved class name is repeated in an Intersection Type, PHP throws a fatal error immediately.

However, class aliases and inheritance chains are not resolved at compile-time, and does not result in errors on redundant classes/interfaces, or class aliases.

Duplicate members detected at compile-time

Each member of an Intersection Type must be used only once. For example, Foo&Bar&Foo is an illegal type declaration because Foo type is used more than once.

Such errors are detected at compile-time, and results in a fatal error.

function foo(Foo&Bar&Foo $val) {}
Fatal error: Duplicate type Foo is redundant in ... on line ...

Redundant members

PHP does not emit any warnings/notices, or throw any exceptions when a redundant class/interface is used in an Intersection Type.

class A {}
class_alias(A::class, 'B');

function foo(A&B $val) {}

foo(new A());
foo(new B());

In the snippet above, the B class in an Intersection Type A&B is redundant because B is aliased to class A. This snippet does not cause any issues because PHP does not resolve class aliases at compile-time.

Static analyzers might be able to point out such redundant Intersection Types.


Variance

Type variance with Intersection Types follow Liskov Substitution Principle, similar to Union Types and the rest of the PHP's type system.

With Intersection Types, "widening" the type means adding new members to the intersection, and "narrowing" the type means removing members in the intersection.


Parameter Types in a parent class method can be extended by a child class, allowing contravariance. With Intersection Types, it means that the child class method can widen its scope by removing members of the Intersection Type, thus, loosening the scope.

class A {
    public function test(Foo&Bar $val) {}
}
class B extends A {
    public function test(Foo $val): Test&dsa {}
}

The snippet above is valid, because B::test method effectively widens its $val parameter by the Bar member from the Intersection Type.


Return types covariance means that the return type of a subclass can be further narrowed. In an Intersection Type, the subclass can add members to the Intersection and still fulfill its contract.

class A {
    public function test(): Foo {}
}
class B extends A {
    public function test(): Foo&Bar {}
}

The return value of B::test method is declared as an Intersection Type Foo&Bar. This is allowed, because return values of B::test continue to meet A::test methods return type.


Property types are invariant, which means the property types cannot be changed at all. Change of the order of members in the property type declarations are allowed, but adding or removing members is not allowed.


Signature mismatches result in a compile-time fatal error:

class Foo {}
class Bar {}

class A {
    public function test(Foo $val) {}
}
class B extends A {
    public function test(Foo&Bar $val) {}
}
Fatal error: Declaration of B::test(Foo&Bar $val) must be compatible with A::test(Foo $val) in ... on line ...

Further, changes of the individual members of an Intersection Type is allowed, as long as the new member is a covariant for return types, or a contra-variant in case of a parameter type.

Nullable Types

Before PHP 7.1 introduced nullable-types (e.g. ?string), there was a way to declare a parameter type as implicitly nullable by setting a default value of null:

function test(string $test = null) {}

PHP also supported passing null to internal functions even if they are not declared as nullable parameters. This has changed in PHP 8.1 to emit a deprecation notice on such cases.

Intersection Types in PHP 8.1 do not support nullable type syntax (introduced in PHP 7.1), and results in a syntax error:

function test(?Foo&Bar $test) {}
Parse error: syntax error, unexpected token "&", expecting variable in ... on line ...

Further, implicit nullable syntax is not allowed with Intersection Types either, and results in a compile-time fatal error.

function test(Foo&Bar $test = null) {}
Fatal error: Cannot use null as default value for parameter $test of type Foo&Bar in ... on line ...

Backwards Compatibility Impact

Intersection Types introduced in PHP 8.1 introduces a new syntax to declare types with the & symbol.

Prior to PHP 8.1, any code that uses Intersection Types results in a syntax error similar to:

PHP Parse error: Syntax error, unexpected T_STRING, expecting T_VARIABLE on line ...

Intersection Types cannot be poly-filled in older PHP versions. However, an alternative approach would be declaring new interface that implements multiple interfaces, and making the classes implement that interface instead:

interface CountableIterator implements \Iterator, \Countable {}

class CountableIteratorItem implements CountableIterator {
    // implement methods from both classes.
}
function count_and_iterate(CountableIterator $value) {
    foreach($value as $val) {}
    count($value);
}

RFC Discussion Implementation