Menu

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 object Collection<Animal> accepts only another Collection<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 object Collection<Animal> also accepts Collection<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.

Theme
A
© 2016–2024 Ondřej Mirtes