DEV Community

Cover image for PHPStan Generics in the Real World: Building a Type-Safe Collection Library
sebk69
sebk69

Posted on

PHPStan Generics in the Real World: Building a Type-Safe Collection Library

PHP has arrays.

They are flexible, fast, convenient… and sometimes far too permissive.

When a project grows, an array<int, mixed> can quietly become an array of strings, objects, missing keys, duplicated values, invalid states, or combinations nobody intended.

That is why I have been working on small/collection, a PHP collection library designed around a simple idea:

A collection should not only contain values. It should express and enforce what those values mean.

Recently, I extended the library with several specialized collection types and, perhaps more importantly, tightened its static type system with PHPStan generics.

This article covers some of the most interesting lessons from that work.

The base collection

The core collection uses two generic parameters:

/**
 * @template TKey of array-key
 * @template TValue
 *
 * @implements \ArrayAccess<TKey, TValue>
 * @implements \Iterator<TKey, TValue>
 */
class Collection implements
    \ArrayAccess,
    \Countable,
    \Iterator,
    \JsonSerializable
{
    /** @var array<TKey, TValue> */
    protected array $array = [];
}
Enter fullscreen mode Exit fullscreen mode

That already gives PHPStan much more information than:

class Collection
{
    protected array $array = [];
}
Enter fullscreen mode Exit fullscreen mode

Instead of treating every collection as mixed, we can describe things such as:

/** @var Collection<string, User> $users */
$users = new Collection();
Enter fullscreen mode Exit fullscreen mode

PHPStan now knows:

  • keys are strings
  • values are User
  • iterator values are User
  • offsetGet() returns User
  • callbacks can receive typed keys and values

But things become more interesting when specialized collections start inheriting from this class.


ListCollection: keys are part of the invariant

A list is not just a collection with integer keys.

A list should guarantee:

0, 1, 2, 3, ...
Enter fullscreen mode Exit fullscreen mode

No gaps.

No string keys.

So ListCollection normalizes and protects its indexes.

/**
 * @template TValue
 * @extends Collection<int, TValue>
 */
class ListCollection extends Collection
{
}
Enter fullscreen mode Exit fullscreen mode

Its API includes operations such as:

$list->append($value);
$list->prepend($value);
$list->insertAt(2, $value);
$list->removeAt(1);
$list->at(3);
$list->shuffle();
Enter fullscreen mode Exit fullscreen mode

The important part is not the convenience methods.

The important part is that the invariant remains true after every operation.

$list = new ListCollection([
    7 => 'foo',
    42 => 'bar',
]);

$list->toArray();

// [
//     0 => 'foo',
//     1 => 'bar',
// ]
Enter fullscreen mode Exit fullscreen mode

This distinction between a generic collection and a list becomes extremely useful once static analysis is involved.


SetCollection: uniqueness belongs in the type

A set should never contain duplicates.

Instead of expecting every caller to remember:

array_unique($values);
Enter fullscreen mode Exit fullscreen mode

the collection itself owns the invariant.

The API can then naturally expose set operations:

$set->union($other);
$set->intersection($other);
$set->difference($other);
$set->symmetricDifference($other);

$set->isSubsetOf($other);
$set->isSupersetOf($other);
$set->equalsSet($other);
Enter fullscreen mode Exit fullscreen mode

One design decision matters here: equality.

For this library, set uniqueness uses strict PHP equality:

$value === $other
Enter fullscreen mode Exit fullscreen mode

That means:

2 !== '2'
Enter fullscreen mode Exit fullscreen mode

and therefore both values may coexist in a set.

This is intentional.

Type-sensitive collections should generally avoid PHP's loose comparison rules unless coercion is explicitly part of the API.


MapCollection: map and list are different abstractions

PHP arrays blur the distinction between lists and maps.

A collection API does not have to.

A MapCollection accepts explicit int|string keys and exposes map-oriented operations:

$map->getOrDefault('timeout', 30);
$map->require('database');
$map->renameKey('old', 'new');
$map->mapKeys(...);
$map->mapValues(...);
$map->hasAllKeys(...);
$map->hasAnyKey(...);
Enter fullscreen mode Exit fullscreen mode

One of the interesting PHPStan improvements was typing mapKeys() correctly.

A bad annotation would be:

/**
 * @return MapCollection<TKey, TValue>
 */
public function mapKeys(callable $callback): MapCollection
Enter fullscreen mode Exit fullscreen mode

That is wrong because the callback can change the key type.

The better version introduces a method-level template:

/**
 * @template TMappedKey of array-key
 *
 * @param callable(TKey, TValue): TMappedKey $callback
 * @return static<TMappedKey, TValue>
 */
public function mapKeys(callable $callback): static
Enter fullscreen mode Exit fullscreen mode

Now PHPStan can understand:

/** @var MapCollection<string, User> $users */

$usersById = $users->mapKeys(
    fn (string $email, User $user): int => $user->id,
);
Enter fullscreen mode Exit fullscreen mode

and infer something equivalent to:

MapCollection<int, User>
Enter fullscreen mode Exit fullscreen mode

This is where generics stop being documentation and start becoming part of the developer experience.


EnumCollection and one of PHPStan's subtle generic traps

Enums are perfect candidates for specialized collections.

/**
 * @template TKey of array-key
 * @template TEnum of \UnitEnum
 *
 * @extends Collection<TKey, TEnum>
 */
class EnumCollection extends Collection
{
}
Enter fullscreen mode Exit fullscreen mode

The collection guarantees two things:

  1. every value implements UnitEnum
  2. all values belong to the same enum class

Example:

enum Status
{
    case Draft;
    case Published;
}

$statuses = new EnumCollection([
    Status::Draft,
    Status::Published,
]);
Enter fullscreen mode Exit fullscreen mode

Factories make this convenient:

$statuses = EnumCollection::fromNames(
    Status::class,
    ['Draft', 'Published'],
);
Enter fullscreen mode Exit fullscreen mode

For backed enums:

enum Role: string
{
    case User = 'user';
    case Admin = 'admin';
}

$roles = EnumCollection::fromValues(
    Role::class,
    ['user', 'admin'],
);
Enter fullscreen mode Exit fullscreen mode

The generic property problem

An interesting PHPStan error appeared around the internal enum class:

/** @var class-string<TEnum>|null */
private ?string $enumClass = null;
Enter fullscreen mode Exit fullscreen mode

At runtime, this looked reasonable.

But checkValue() receives:

mixed
Enter fullscreen mode Exit fullscreen mode

and only later verifies:

$value instanceof \UnitEnum
Enter fullscreen mode Exit fullscreen mode

PHPStan correctly pointed out that:

class-string<UnitEnum>
Enter fullscreen mode Exit fullscreen mode

is not necessarily:

class-string<TEnum>
Enter fullscreen mode Exit fullscreen mode

The fix was not to silence PHPStan.

The correct fix was to model the internal runtime state honestly:

/** @var class-string<\UnitEnum>|null */
private ?string $enumClass = null;
Enter fullscreen mode Exit fullscreen mode

The public collection still has:

TEnum
Enter fullscreen mode Exit fullscreen mode

but the internal invariant tracker only needs to know that it stores the class name of some enum.

This is an important lesson:

A generic parameter should only be used where the program can actually guarantee that relationship.

More precise-looking PHPDoc is not automatically more correct.


DateTimeCollection: preserve the concrete date type

A first attempt might look like this:

/**
 * @template TKey of array-key
 * @extends Collection<TKey, \DateTimeInterface>
 */
class DateTimeCollection extends Collection
{
}
Enter fullscreen mode Exit fullscreen mode

This works, but loses useful information.

If the caller gives the collection only DateTimeImmutable objects, methods such as:

earliest()
latest()
closestTo()
Enter fullscreen mode Exit fullscreen mode

should ideally return:

DateTimeImmutable|null
Enter fullscreen mode Exit fullscreen mode

not just:

DateTimeInterface|null
Enter fullscreen mode Exit fullscreen mode

A better declaration is:

/**
 * @template TKey of array-key
 * @template TDateTime of \DateTimeInterface
 *
 * @extends Collection<TKey, TDateTime>
 */
class DateTimeCollection extends Collection
{
}
Enter fullscreen mode Exit fullscreen mode

Now methods can return:

/** @return TDateTime|null */
public function earliest(): ?\DateTimeInterface
Enter fullscreen mode Exit fullscreen mode

The runtime signature remains compatible with PHP, while PHPStan preserves the more precise generic type.

The collection also exposes domain-appropriate operations:

$dates->earliest();
$dates->latest();

$dates->between($from, $to);
$dates->before($date);
$dates->after($date);

$dates->sortChronologically();

$dates->groupByDay();
$dates->groupByMonth();

$dates->closestTo($date);
Enter fullscreen mode Exit fullscreen mode

A collection becomes much more valuable when its API speaks the language of its values.


BooleanCollection

A boolean collection sounds simple, but a dedicated type makes aggregate logic much clearer:

$flags->allTrue();
$flags->anyTrue();
$flags->noneTrue();

$flags->countTrue();
$flags->countFalse();

$flags->and();
$flags->or();
$flags->xor();

$inverted = $flags->invert();
Enter fullscreen mode Exit fullscreen mode

An interesting semantic choice is xor().

For the collection implementation, XOR follows parity:

true when an odd number of values are true
Enter fullscreen mode Exit fullscreen mode

This generalizes the normal two-value XOR operation.


IntegerCollection and FloatCollection should not be the same thing

It is tempting to create one numeric collection and stop there.

But integers and floats have very different operations.

IntegerCollection

Integer-specific operations include:

IntegerCollection::range(1, 100);

$numbers->gcd();
$numbers->lcm();

$numbers->evenValues();
$numbers->oddValues();

$numbers->bitAnd();
$numbers->bitOr();
$numbers->bitXor();

$numbers->median();
$numbers->mode();
Enter fullscreen mode Exit fullscreen mode

Bitwise operations were also a good reminder that tests themselves can be wrong.

For example:

6 & 3
Enter fullscreen mode Exit fullscreen mode

is:

2
Enter fullscreen mode Exit fullscreen mode

not 0.

A complete test suite is useful not only for discovering implementation bugs, but also for challenging incorrect assumptions in test expectations.

FloatCollection

Floating point values introduce another category of concerns:

$floats->epsilonEquals($other, 0.00001);

$floats->round(2);
$floats->floor();
$floats->ceil();

$floats->isFinite();
$floats->withoutNan();
Enter fullscreen mode Exit fullscreen mode

A floating-point collection should also define explicit semantics for:

NAN
INF
-INF
Enter fullscreen mode Exit fullscreen mode

Leaving those cases implicit eventually creates surprises.


ObjectCollection

An object collection can provide generic object-oriented operations without becoming domain specific:

$objects->instancesOf(User::class);

$names = $objects->property('name');

$results = $objects->method('calculate');

$indexed = $objects->indexByProperty('id');
Enter fullscreen mode Exit fullscreen mode

The important design decision was to keep it generic.

I deliberately did not add collections such as:

EmailCollection
UuidCollection
UrlCollection
MoneyCollection
Enter fullscreen mode Exit fullscreen mode

Those belong either in domain packages or value-object libraries.

A reusable collection library should provide structural specializations, not try to predict every application domain.


SortedCollection: sorting as a permanent invariant

Sorting a normal collection is an operation:

$collection->sortByCallback($comparator);
Enter fullscreen mode Exit fullscreen mode

A SortedCollection is different.

Sorting is part of the type invariant.

Once a comparator is configured, every insertion or replacement must preserve order.

$sorted = new SortedCollection(
    values: $items,
    comparator: fn ($a, $b) => $a->priority <=> $b->priority,
);
Enter fullscreen mode Exit fullscreen mode

After:

$sorted->set('new', $item);
Enter fullscreen mode Exit fullscreen mode

the collection must still be sorted.

This illustrates a useful distinction:

Operations describe what a collection can do. Invariants describe what a collection is.


ImmutableCollection: immutability is an API family

Immutability is different from IntegerCollection or EnumCollection.

It is not primarily about value type.

It changes the mutation model.

Instead of:

$collection['foo'] = 'bar';
Enter fullscreen mode Exit fullscreen mode

an immutable API uses:

$newCollection = $collection->with('foo', 'bar');
Enter fullscreen mode Exit fullscreen mode

Other operations include:

$new = $collection->without('foo');

$new = $collection->appended('bar');
Enter fullscreen mode Exit fullscreen mode

Direct mutations throw:

$collection['foo'] = 'bar';
unset($collection['foo']);
$collection->push('bar');
Enter fullscreen mode Exit fullscreen mode

This is why I implemented immutability as both:

ImmutableCollection
Enter fullscreen mode Exit fullscreen mode

and:

ImmutableCollectionInterface
Enter fullscreen mode Exit fullscreen mode

rather than as another value specialization.


static is extremely useful in collection PHPDoc

Suppose a base method creates:

new static()
Enter fullscreen mode Exit fullscreen mode

and preserves keys and values.

The return type should often be:

@return static
Enter fullscreen mode Exit fullscreen mode

instead of:

@return Collection<TKey, TValue>
Enter fullscreen mode Exit fullscreen mode

For example:

/**
 * @return static
 */
public function filterByCallback(callable $callback): Collection
Enter fullscreen mode Exit fullscreen mode

Now this:

/** @var DateTimeCollection<int, DateTimeImmutable> $dates */

$result = $dates->filterByCallback(...);
Enter fullscreen mode Exit fullscreen mode

can remain a DateTimeCollection from the static analyzer's point of view.

This matters for fluent APIs.

Without it, every inherited operation slowly degrades back to the base Collection.


But do not use static when the generic shape changes

There is a catch.

Consider:

values()
Enter fullscreen mode Exit fullscreen mode

It removes the original keys and produces integer indexes.

If we start with:

Collection<string, User>
Enter fullscreen mode Exit fullscreen mode

the result is conceptually:

Collection<int, User>
Enter fullscreen mode Exit fullscreen mode

Returning simply:

static
Enter fullscreen mode Exit fullscreen mode

would preserve the subclass, but not correctly express the changed generic key.

Sometimes PHP/PHPStan cannot express every relationship we would ideally like.

The goal is not maximum cleverness.

The goal is the most accurate contract the type system can honestly represent.


Conditional return types for toArray()

The base collection has:

toArray(
    bool $keepKey = true,
    bool $recursive = true,
)
Enter fullscreen mode Exit fullscreen mode

The result changes depending on the arguments.

Instead of declaring only:

@return array
Enter fullscreen mode Exit fullscreen mode

PHPStan can model it with a conditional type:

/**
 * @return array<array-key, mixed>
 *
 * @phpstan-return (
 *     $recursive is false
 *         ? (
 *             $keepKey is true
 *                 ? array<TKey, TValue>
 *                 : list<TValue>
 *         )
 *         : (
 *             $keepKey is true
 *                 ? array<TKey, mixed>
 *                 : list<mixed>
 *         )
 * )
 */
public function toArray(
    bool $keepKey = true,
    bool $recursive = true,
): array
Enter fullscreen mode Exit fullscreen mode

This also allowed removing an old:

@phpstan-ignore-next-line
Enter fullscreen mode Exit fullscreen mode

because PHPStan could finally understand the actual contract.

That is one of my favorite outcomes of better type documentation:

Good types can remove static-analysis suppressions instead of adding more of them.


RecordCollection needed more than one template

Another interesting case was a collection of records.

A simplistic declaration would be:

/**
 * @template TKey of array-key
 * @extends Collection<TKey, Record>
 */
class RecordCollection extends Collection
{
}
Enter fullscreen mode Exit fullscreen mode

But Record itself is generic.

So the useful declaration becomes:

/**
 * @template TKey of array-key
 * @template TRecordKey of array-key
 * @template TRecordValue
 *
 * @extends Collection<
 *     TKey,
 *     Record<TRecordKey, TRecordValue>
 * >
 */
class RecordCollection extends Collection
{
}
Enter fullscreen mode Exit fullscreen mode

Those templates then need to propagate into:

  • selectors
  • conditions
  • brackets
  • record adapters
  • transformations

Generics are only as good as their weakest propagation point.

If one intermediate class falls back to:

RecordCollection
Enter fullscreen mode Exit fullscreen mode

without parameters, much of the static information disappears.


One surprising Selector detail

While auditing the selector API, I initially expected:

execute()
Enter fullscreen mode Exit fullscreen mode

to preserve the source collection keys.

Then I checked the implementation.

It does:

$result[] = $record;
Enter fullscreen mode Exit fullscreen mode

That means the result is reindexed.

So the correct return type is closer to:

RecordCollection<int, TRecordKey, TRecordValue>
Enter fullscreen mode Exit fullscreen mode

not:

RecordCollection<TKey, TRecordKey, TRecordValue>
Enter fullscreen mode Exit fullscreen mode

The lesson is simple:

Do not write PHPDoc based on what an API looks like it should do. Type what the runtime actually does.

Static analysis is especially valuable here because it forces API assumptions to become explicit.


When PHPStan finds a design problem instead of a PHPDoc problem

Not everything can be solved with annotations.

NumericCollection is a good example.

Its runtime currently accepts values using:

is_numeric($value)
Enter fullscreen mode Exit fullscreen mode

which includes:

42
42.5
"42"
"42.5"
Enter fullscreen mode Exit fullscreen mode

So a completely honest type would include:

numeric-string
Enter fullscreen mode Exit fullscreen mode

But then consider:

IntegerCollection extends NumericCollection
FloatCollection extends NumericCollection
Enter fullscreen mode Exit fullscreen mode

If we propagate:

@template TNumeric of int|float|numeric-string
Enter fullscreen mode Exit fullscreen mode

we create awkward contracts in the specialized classes.

There is another issue.

Even if:

IntegerCollection
Enter fullscreen mode Exit fullscreen mode

starts with integers, an inherited operation such as division can produce floats.

So this:

/**
 * @extends NumericCollection<TKey, int>
 */
Enter fullscreen mode Exit fullscreen mode

would be misleading unless arithmetic operations are redesigned.

This is not a PHPDoc problem anymore.

It is an API architecture question.

Possible future directions include:

  • arithmetic methods returning a different collection type
  • IntegerCollection::divide() returning FloatCollection
  • separating integer-preserving and numeric-promoting operations
  • tightening NumericCollection runtime validation

For now, I prefer leaving a known architectural limitation over publishing a beautiful but false type contract.


Static analysis and runtime tests complement each other

For this work, the validation target is strict:

PHPStan: 0 errors
196 tests
786 assertions
100% line coverage
Enter fullscreen mode Exit fullscreen mode

The important part is that these tools catch different classes of problems.

PHPStan found things such as:

class-string<TEnum>
Enter fullscreen mode Exit fullscreen mode

being assigned a value that could only be proven as:

class-string<UnitEnum>
Enter fullscreen mode Exit fullscreen mode

Runtime tests found things such as:

  • incorrect bitwise expectations
  • incorrect inclusive DateTime boundary expectations
  • assumptions about nested collections
  • sorting callbacks receiving Collection objects instead of raw arrays

Coverage found branches that had never actually been exercised.

None of these tools replaces the others.


Avoid using PHPStan ignores as type design

There are legitimate cases for:

@phpstan-ignore-next-line
Enter fullscreen mode Exit fullscreen mode

especially around highly dynamic code.

But during this refactor I found several places where better PHPDoc made ignores unnecessary.

That should usually be the preference.

Before adding:

@phpstan-ignore-next-line
Enter fullscreen mode Exit fullscreen mode

ask:

  1. Is the code actually safe?
  2. Can the type be expressed better?
  3. Is the implementation violating its documented generic contract?
  4. Is this exposing a real API design problem?

A static analyzer complaining about generic variance is often telling you something meaningful.


Specialized collections are more than helper methods

The biggest lesson from this work is that specialized collections are useful because they combine three things:

Runtime invariants

ListCollection
→ consecutive integer indexes

SetCollection
→ unique values

EnumCollection
→ one enum class

IntegerCollection
→ integer values

SortedCollection
→ permanent ordering

ImmutableCollection
→ no direct mutation
Enter fullscreen mode Exit fullscreen mode

Domain-appropriate APIs

DateTimeCollection::earliest()
SetCollection::intersection()
MapCollection::renameKey()
BooleanCollection::allTrue()
Enter fullscreen mode Exit fullscreen mode

Static contracts

@template
@extends
@implements
class-string<T>
conditional return types
static return types
Enter fullscreen mode Exit fullscreen mode

When those three layers agree, collections become significantly more useful than typed wrappers around arrays.


Final thoughts

PHP's type system is much stronger today than it used to be, but advanced collection APIs still rely heavily on static-analysis tools such as PHPStan.

Used carefully, PHPDoc generics can express relationships PHP itself cannot yet encode:

Collection<TKey, TValue>
MapCollection<TKey, TValue>
DateTimeCollection<TKey, TDateTime>
EnumCollection<TKey, TEnum>
RecordCollection<TKey, TRecordKey, TRecordValue>
Enter fullscreen mode Exit fullscreen mode

The important word is carefully.

The goal should never be:

How can I make PHPStan stop complaining?

The better question is:

What contract does this code actually guarantee?

When the annotation follows that answer, PHPStan becomes less of a linter and more of an API design tool.

And that is where generics become really interesting in PHP.

Links

Repository : https://git.small-project.dev/lib/small-collection
Packagist : https://packagist.org/packages/small/collection

Top comments (0)