levelmax World 2 Honest Types
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. B
L
Level 2.5 ~15 min Target: PHPStan level 7

Generics with @template

A collection of objects works perfectly at runtime. Then you write $products->first()->name, and PHPStan says object has no property $name. You could write a ProductCollection, then an OrderCollection, then a CustomerCollection, all the same apart from the type. Or you could declare the type once and let it vary.

@template

@template T gives a class a type parameter. Use T anywhere in its docblocks that the item type goes:

/**
 * @template T
 */
final class Queue
{
    /** @param list<T> $items */
    public function __construct(private array $items) {}

    /** @return T|null */
    public function next(): mixed
    {
        return array_shift($this->items);
    }
}

Now Queue<Email> is a type you can put on a parameter or property, and PHPStan knows next() gives you an Email or null. You rarely have to write it when you create one, because PHPStan works out T from the constructor: new Queue([$welcome, $receipt]) is a Queue<Email>.

PHP has no native generics, so the native return type stays mixed and the docblock says T.

Methods can have their own

Some methods return a different type from the one the class holds. Give the method its own template, and PHPStan works it out from the arguments at each call:

/**
 * @template T
 * @param callable(): T $compute
 * @return T
 */
public function remember(string $key, callable $compute): mixed

$cache->remember('stock', fn (): int => $this->countStock()) returns an int, with no @var needed. A collection's map() works the same way: it takes Ts and returns a collection of U, whatever the callback returns.

Generic types you already use

IteratorAggregate, Traversable, ArrayIterator and Generator are all generic. From level 6, PHPStan asks what they contain, just as it asks about arrays: @implements IteratorAggregate<int, T>, @return Generator<int, Product>.

Power-up

Before
<?php

final class Cache
{
    public function remember(string $key, callable $compute): mixed
    {
        return $this->items[$key] ??= $compute();
    }
}

/** @var int $stock   every caller has to say what came back */
$stock = $cache->remember('stock', fn () => $this->countStock());
After
<?php

final class Cache
{
    /**
     * @template T
     * @param callable(): T $compute
     * @return T
     */
    public function remember(string $key, callable $compute): mixed
    {
        return $this->items[$key] ??= $compute();
    }
}

// PHPStan knows $stock is an int, from the callback.
$stock = $cache->remember('stock', fn (): int => $this->countStock());

Quick check

1. Collection declares @template T and takes list<T> in its constructor. What type is new Collection([new Product('Mug', 3)])?
2. Why does map() need its own @template U, instead of using the class's T?
3. Mug extends Product. You pass a Collection<Mug> to a parameter typed Collection<Product>. What does PHPStan say?

CHALLENGE

Collection holds objects, so everything that comes out of it is just an object. Shop (read only) knows it has a Collection<Product>, but PHPStan can't see a Product anywhere, and Shop needs a map() that doesn't exist yet.

Make src/Collection.php generic, keeping the type of its items through filter(), first(), all() and iteration. Then add map(), which returns a new collection of whatever the callback returns. The tests and PHPStan at level 7 should both pass.

Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.