Menu

A guide to call-site generic variance

September 19, 2023 · 8 min read

PHPStan has supported what is called declaration-site variance for a long time. An example you might be familiar with is the infamous @template-covariant. And although not as often useful, it also has a contravariant counterpart.

To freshen your memory: you can mark a template type as covariant:

/** @template-covariant ItemType */
interface Collection
{
	/** @param ItemType $item */
	public function add(mixed $item): void;

	/** @return ItemType|null */
	public function get(int $index): mixed;
}

This allows you to pass Collection<Cat> into functions where Collection<Animal> is expected. But it comes at a cost:

/** @param Collection<Animal> $animals */
function foo(Collection $animals): void
{
	$animals->add(new Dog());
}

By itself, this is a perfectly valid code. But with Collection being covariant over its template type, we can now mix cats with dogs. That’s why PHPStan prevents us from using the ItemType in Collection::add() method’s parameter:

Template type ItemType is declared as covariant, but occurs in contravariant position in parameter item of method Collection::add().

That’s the trade-off: if you want the Collection to be covariant, it can only have the get method. If you want it to have the add method too, it has to be invariant in its ItemType.

Until now, there has not been an easy way around this. You could split the interface into a read-only one that can safely be covariant, and an invariant one:

/** @template-covariant ItemType */
interface ReadonlyCollection
{
	/** @return ItemType|null */
	public function get(int $index): mixed;
}

/**
 * @template ItemType
 * @extends ReadonlyCollection<ItemType>
 */
interface Collection extends ReadonlyCollection
{
	/** @param ItemType $item */
	public function add(mixed $item): void;
}

But that is a lot of work and can quickly get tedious. Call-site variance is a way of having PHPStan do this work for you.

Call-site variance #

As the name suggests, call-site variance (or type projections, if you prefer fancier words) moves the variance annotation from the declaration to the call site. This means that the declaration of the interface can remain invariant, and therefore contain both get and add methods:

/** @template ItemType */
interface Collection
{
	/** @param ItemType $item */
	public function add(mixed $item): void;

	/** @return ItemType|null */
	public function get(int $index): mixed;
}

If you need a specific function to accept Collection<Cat> in place of Collection<Animal>, you can instruct it so by attaching the covariant keyword to the generic type argument:

/** @param Collection<covariant Animal> $animals */
function foo(Collection $animals): void
{
	$animals->add(new Dog());
}

Correspondingly, the error has moved from the declaration to the call-site: if the implementation does something that would break type safety, like adding a Dog into the collection above, PHPStan will tell us:

Parameter #1 $item of method Collection<covariant Animal>::add() expects never, Dog given.

Call-site contravariance #

Although not as useful, it’s worth mentioning that you can also use contravariant type projections. For example, if we wanted a happy little function that fills a collection with dogs, we could make it so that it accepts not only Collection<Dog>, but also Collection<Animal>, or even Collection<mixed>:

/** @param Collection<contravariant Dog> $collection */
function fill(Collection $collection)
{
	while (true) {
		$collection->add(new Dog);
	}
}

This too has a limitation, but at least this time it’s not so drastic: if we wanted to get a value from the collection, we can:

/** @param Collection<contravariant Dog> $collection */
function fill(Collection $collection)
{
	while ($collection->get(42) === null) {
		$collection->add(new Dog);
	}
}

But because a contravariant type is not bounded from the top, we cannot make any assumption about the type of the retrieved item, and neither can PHPStan, therefore the type of $collection->get(42) would be mixed.

Star projections #

Sometimes, you just don’t care about the type of the values you’re working with. Let’s add a count() method to the Collection:

/** @template ItemType */
interface Collection
{
	/** @param ItemType $item */
	public function add(mixed $item): void;

	/** @return ItemType|null */
	public function get(int $index): mixed;

	public function count(): int;
}

This method doesn’t reference the ItemType template type at all. We are using it in a function printSize that prints the size of a collection. In this case, the function can accept a collection of anything. Inspired by other languages such as Kotlin, PHPStan provides an idiomatic way of writing this, using an asterisk:

/** @param Collection<*> $collection */
function printSize(Collection $collection): int
{
	echo $collection->count();
}

Obviously, we cannot make any assumptions about the collection’s item type whatsoever. In other words, star projections combine the limitations of covariant and contravariant projections. If we were to add() anything into the collection inside this printSize function, we would get a similar error as above:

Parameter #1 $item of method Collection<*>::add() expects never, Dog given.

And if we wanted to get a value from the collection, it would be mixed:

/** @param Collection<*> $collection */
function printSize(Collection $collection): int
{
	$item = $collection->get(0);
	// $item is mixed
}
Theme
A
© 2016–2024 Ondřej Mirtes