PHP 8.0: Union Types

Version8.0
TypeNew Feature

PHP has come a long way with types. We now have scalar types, return types, nullable types, and even property types in PHP 7.4!

PHP 8.0 comes with support for Union Types!

In versions prior to PHP 8.0, you could only declare a single type for properties, parameters, and return types. PHP 7.1 and newer versions have nullable types, which means you can declare the type to be null with a type declaration similar to ?string.

From PHP 8.0, you can declare more than one type for arguments, return types, and class properties.

class Example {
    private int|float $foo;
    public function squareAndAdd(float|int $bar): int|float {
        return $bar ** 2 + $foo;
    }
}

Similar to types enforced in previous PHP versions, PHP will now make sure the function parameters, return types, and class properties belong to one of the types declared in the definition.

Union types prior to PHP 8.0

Nullable types

PHP already has support for nullable types; You can declare a type as nullable by prefixing a ? sign to the type. For example, if a certain property can be a string or a null, you can declare it as ?string. With PHP 8.0 Union Types, string|null will be functionally equivalent to ?string.

iterable type

There is also the iterable pseudo-type that is functionally equivalent to array|Traversable.

PHPDoc comments

PHPDoc standard had support for Union Types. You would have used a PHPDoc comment to declare the types allowed for a certain code block:

class Example {  

 /**  
  * @var int|float  
  */  
  private $foo;  

  /**  
   * @param int|float $bar  
   * @return int|float  
   */  
  public function squareAndAdd(int $bar): int {  
    return $bar ** 2 + $this->foo;  
  }
 }

Union Types in PHP 8.0

From PHP 8.0, you can now declare any number of arbitrary types to for properties, arguments, and return types. There are a few exceptions that don't make sense to be used together, such as string|void.

You can completely get rid of the @var, @param, and @return PHPDoc comments now in favor of the types enforced in the code itself. This will certainly help clean the boilerplate comments that were there purely because they could not be declared in code before.

Existing nullable types are not removed, nor deprecated. You can continue to use ?string as a shorthand to string|null.

The iterable type is not going to be removed either, and will be functionally equal to array|Traversable.

The RFC further explains certain restrictions and other improvements to Union Types:

The void type is not allowed in a Union

PHP already has support for void type. It is only allowed as a return type because using void anywhere else wouldn't make any sense.
Either the function must not return any value, or call return; without specifying a value.
In PHP 8.0 Union Types, the void type is not allowed to be combined with any other type.

For example, the following is not allowed:

function foo(): void|null {}

Special false type

PHP core and many legacy libraries return false to indicate negative result.

For example, if you have a function that loads use account by its ID, it might return false to indicate that the user does not exists.

To help the adoption of union types, it is allowed to use false as part of the union types.

function user_load(int $id): User|false {
}

The snippet above mimics Drupal's user_load() function, where it returns a User object if the user account is found, or false otherwise. This special false type can be quite helpful in this situation.

There are several PHP core functions that follow the same pattern too. For example, strpos() function returns an int of the position where the needle bytes were found in the string, or false if it is not found anywhere. The special false type can come handy here too.

  • false cannot be used as a standalone type. This means a type declaration like public false $foo is not allowed. You can still use bool type for it.
  • There is no true pseudo type. The aim of the false pseudo type is to accommodate the (mostly legacy) code that returns false on failure. Use bool type there.
  • false pseudo type is allowed anywhere types are allowed: class properties, function arguments, and return types all support false type.
  • *If bool is used, false cannot be used in same type declaration.

Nullable types (?TYPE) and null must not be mixed.

While ?string is a shorthand for string|null, the two notations must not be mixed.

If your type declaration has more than one type and null, it must be declared as the following:

TYPE_1|TYPE_2|null

It must not be declared as ?TYPE_1|TYPE_2 as this would be an ambiguous declaration.

Compile-time error checking

Union types does not allow duplicate or redundant types in type declaration. This check will happen at compile-time without autoloading classes/interfaces.

Duplicate types are not allowed.

You cannot declare int|int or int|INT as this essentially is the same. Furthermore, int|?int is not allowed either.
The latter will result in a syntax error, and the former will throw this error:

Fatal error: Duplicate type ... is redundant in ... on line ...

Redundant types are not allowed.

Union types does not allow redundant class types. However, it is important to note that this only applies to a few special special types only.

  • bool|false is not allowed because false is a type of bool.
  • `object cannot be used with a class name because all class objects are of type object too.
  • iterable cannot be used with array or Traversable because iterable is a union type array|Traversable already.
  • Class names can be used even if one extends another.

Because the Union types declarations are validated at compile-time, there will be no errors thrown if you use a parent class and a child class in union because that would require class hierarchy resolution.

Here are some examples:

function foo(): bool|false {} // Fatal error: Duplicate type false is redundant in ... on line ...
function foo(): DateTime|object {} // Fatal error: Type DateTime|object contains both object and a class type, which is redundant in ... on line ...
function foo(): iterable|array {} // Fatal error: Type iterable|array contains both iterable and array, which is redundant in ... on line ...

Because the type declaration is only checked at compile-time, the following is valid:

class A{}
class B extends A{}
function foo(): A|B {}

Type variance

Union type variance follows LSP. This is to ensure that all sub classes and implementations of the interface must not change the behavior of the program and still adhere to the contract. This is enforced in PHP even without Union types.

  • Parameter are contra-variant: Types can be widened with a super type.
  • Return types are covariant: Types can be restricted to a sub type.
  • Property types invariant: Types cannot be changed to a sub or super type.

Adding or removing types to a Union

class A{
    public function foo(string|int $foo): string|int {}
}
class B extends A{
    public function foo(string|int|float $foo): string {}
}

In the snippet above, parameter types are widened (with the addition of float type). This is allowed because all programs that use class B expect them to accept all types class A accepts. Class B still fulfills this contract.

Return type in B::foo is restricted further, but this still follows LSP because class B fulfils the return type declared in A::foo.

If you were to change the type in a way that class B does not fulfill the contract, this will trigger a fatal error:

class A{
    public function foo(string|int $foo): string|int {}
}
class B extends A{
    public function foo(string|float $foo): string|int {}
}
Fatal error: Declaration of B::foo(string|float $foo): string|int must be compatible with A::foo(string|int $foo): string|int in ... on line ...

Variance of individual types in a Union

Same variance rules are followed when you change individual members a union type.

Subclasses

class ParentClass {}
class ChildClass extends ParentClass{}

class FooParent {
    public function foo(string|ChildClass $a): string {}
    public function bar(string $b): string:ParentClass {}
}

class FooChild extends FooParent{
    public function foo(string|ParentClass $a): string|ChildClass {}    
    public function bar(string $b): string:ChildClass {}
}

This is perfectly, because in the FooChild::foo functions parameters are expanded. All existing code that works with FooParent class will work because ChildClass is a sub-type of ParentClass with same functionality.

In FooChild::bar function, its return type is further restricted. This is also valid because FooChild::bar still fulfills its contract by return a sub-type of string or ChildClass which inherits ParentClass.

Nullable

class FooParent {
    public function foo(string $a): int|null {}
}

class FooChild extends FooParent{
    public function foo(string|null $b): int|null {}    
}

Return types are allowed to lose nullable type, and parameters are allowed to make they accept null.

bool and false

In a Union type, PHP considers false to be a sub-type for bool. This allows variance like this:

class FooParent {
    public function foo(int|false $a): int|bool {}
}

class FooChild extends FooParent{
    public function foo(int|bool $b): int|false {}  
}

Backwards compatibility impact

Union Types is a new feature in PHP, and its new syntax does not break any existing functionality. Because this is a syntax change in the engine, this functionality cannot be brought to older versions with a poly-fill.

RFC Externals.io discussion Implementation