Skip to content

Typed collections without generics

PHP does not have generics. A method that returns array gives no compile-time guarantee about what the array contains. PHPDoc annotations like @return array<Book> help static analysers, but they are invisible at runtime. A wrong element slips in, and the error surfaces far from the insertion point.

The problem with raw arrays

Repository methods typically return arrays of domain entities. The consuming code assumes every element is a Book, but nothing enforces it. A mapping error in the repository, a bad test fixture, or a refactoring that changes the hydration logic, and the array silently contains something unexpected.

The type error shows up later, when the code tries to call a method on the wrong object. The stack trace points to the consumer, not to the source of the problem.

A typed wrapper

Phexium provides AbstractTypedCollection, an abstract class that enforces element types at runtime. A concrete collection declares the accepted type through a single method:

final class BooksCollection extends AbstractTypedCollection
{
    protected function type(): string
    {
        return Book::class;
    }
}

Every element is validated on construction and on insertion, using beberlei/assert:

abstract class AbstractTypedCollection extends AbstractCollection
{
    public function __construct(array $collection = [])
    {
        Assert::thatAll($collection)->isInstanceOf($this->type());
        parent::__construct($collection);
    }

    public function add(mixed $element): void
    {
        Assert::that($element)->isInstanceOf($this->type());
        parent::add($element);
    }

    abstract protected function type(): string;
}

If a Loan object ends up in a BooksCollection, an InvalidArgumentException is thrown at the point of insertion. The feedback is immediate.

Six interfaces, one collection

The full API is defined by six segregated interfaces, each backed by a dedicated trait:

Interface Methods
ReadableCollectionInterface items, values, first, last, isEmpty
NavigableCollectionInterface keys, containsKey, get
MutableCollectionInterface add, remove, clear
FunctionalCollectionInterface map, filter, reduce, each
SearchableCollectionInterface find, every, some, contains
TransformableCollectionInterface slice, take, sort, reverse, unique

CollectionInterface composes all six, plus PHP's Countable and IteratorAggregate. AbstractCollection wires the traits together. AbstractTypedCollection adds the type guard on top.

The interface segregation matters when a method only needs to iterate or search, it can declare a narrower dependency instead of requiring the full collection API.

Type preservation through static

All transformation and functional methods return static:

trait TraitCollectionFunctional
{
    public function map(callable $fn): static
    {
        return static::fromArray(array_map($fn, $this->collection));
    }

    public function filter(callable $fn): static
    {
        return static::fromArray(array_filter($this->collection, $fn, ARRAY_FILTER_USE_BOTH));
    }
}

static::fromArray() calls the constructor of the concrete class. A BooksCollection::filter(...) returns a BooksCollection, not a generic AbstractCollection. The type constraint propagates through chained operations.

Usage in repositories

Repository interfaces declare the collection type in their return signatures:

interface BookRepository
{
    public function findAll(): BooksCollection;
    public function findBy(SpecificationInterface $specification): BooksCollection;
}

The repository implementation builds the collection from raw database rows using the fromMap factory:

public function findAll(): BooksCollection
{
    $rows = $this->driver->fetchAll('SELECT * FROM books');

    return BooksCollection::fromMap(
        $rows,
        fn (array $row): Book => $this->mapper->fromRow($row)
    );
}

fromMap applies the callable to each element, then passes the result through the constructor, where the type check runs. If the mapper returns something other than a Book, the collection rejects it on the spot.

What this gives you in practice

The consumer of a BooksCollection does not need to check element types. It can call filter, sort, or find and get back a BooksCollection with the same guarantee. The operations compose without losing type safety.

The trade-off is explicit: runtime validation has a cost per element. For domain collections, dozens or hundreds of entities loaded from a repository, the overhead is negligible. For processing millions of rows, raw arrays remain the right tool.

The collection classes themselves are minimal. BooksCollection is four lines of code. All behavior comes from the abstract classes and traits. Adding a new typed collection for a new aggregate takes a single class with a single method.