What’s Up With @template-covariant?
March 14, 2022 · 4 min read
Let’s say you have a hierarchy like this:
interface Animal {}
class Dog implements Animal {}
class Cat implements Animal {}
When thinking about generics in PHP using PHPDocs, it’s reasonable to expect that Collection<Cat>
can be passed into a parameter where Collection<Animal>
is expected, since Cat
is an Animal
. It might surprise you when PHPStan reports this error:
Parameter #1 $animals of function foo expects
Collection<Animal>
,Collection<Cat>
given.
This is because @template
is invariant, meaning that Collection<Animal>
only accepts another Collection<Animal>
.
Consider this code:
/** @param Collection<Animal> $animals */
function foo(Collection $animals): void
{
$animals->add(new Dog()); // valid, no harm done
}
Because the code is harmless and no error is reported there, we can’t pass Collection<Cat>
there, because it’d no longer be a collection of cats, there’d be a dog among them after this call.
So if we want to pass Collection<Cat>
into Collection<Animal>
, the template type in question has to use @template-covariant
PHPDoc tag. But it comes with a different limitation.
Since we can now pass Collection<Cat>
into Collection<Animal>
, this code is no longer valid:
/** @param Collection<Animal> $animals */
function foo(Collection $animals): void
{
$animals->add(new Dog());
}
The @template-covariant X
tag doesn’t actually allow you to use X
in a parameter position, so with code like this:
/**
* @template-covariant TItem
*/
class Collection
{
/** @var TItem[] */
private array $items = [];
/** @param TItem $item */
public function add($item): void
{
// you can pass Collection<Cat> to Collection<Animal>, but you can't have "TItem" in parameter position
$this->items[] = $item;
}
/** @return TItem|null */
public function get(int $index)
{
return $this->items[$index] ?? null;
}
}
You’ll get this error:
Template type TItem is declared as covariant, but occurs in contravariant position in parameter item of method
Collection::add()
.
To summarize:
@template
declares an invariant type variable: The objectCollection<Animal>
accepts only anotherCollection<Animal>
. But the collection can be mutable and the type variable can be present in a parameter position. Playground example »@template-covariant
declares a covariant type variable: The objectCollection<Animal>
also acceptsCollection<Cat>
, but the type variable cannot be present in a parameter position. Playground example »
Call-site variance #
There is also an elegant way to have an invariant Collection
but still be able to accept Collection<Cat>
in a parameter of type Collection<Animal>
. You can simply put the covariance annotation to the site of use:
/** @param Collection<covariant Animal> $animals */
function foo(Collection $animals): void
{
$animals->add(new Dog());
}
This function will accept Collection<Cat>
. It is still unsafe to add a dog into the collection, but this time you can keep the add
method in the Collection
, and you will only get an error on the line where it is called:
Parameter #1 $item of method
Collection<covariant Animal>::add()
expects never, Dog given.
Learn more in the guide to call-site variance.