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:
@templatedeclares 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-covariantdeclares 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.