Skip to content

feat: support generics of PHP internal classes - #833

Merged
romm merged 2 commits into
CuyZ:masterfrom
romm:feat/support-internal-class-generics
Aug 10, 2026
Merged

feat: support generics of PHP internal classes#833
romm merged 2 commits into
CuyZ:masterfrom
romm:feat/support-internal-class-generics

Conversation

@romm

@romm romm commented Aug 10, 2026

Copy link
Copy Markdown
Member

The mapper learns about generics by reading the @template annotations of a class docblock. Classes internal to PHP, or provided by an extension, carry no such docblock and cannot be modified, so the type of the values they hold could never be described.

Their signature is now known to the mapper, and the ones a source can be mapped to without ambiguity are built with no configuration:

final class Article
{
    public function __construct(
        public string $title,
        /** @var \Ds\Set<string> */
        public \Ds\Set $tags,
    ) {}
}

(new \CuyZ\Valinor\MapperBuilder())->mapper()->map(Article::class, [
    'title' => 'Some article',
    'tags' => ['php', 'valinor'],
]);

An array builds ArrayObject, ArrayIterator and Ds\Map, a list the Spl* and Ds\* collections, a key and a value Ds\Pair. The others are described but never built, until a constructor is registered for them: SplHeap is abstract, SplObjectStorage is keyed by objects, the priority queues need a priority for each value, Generator, WeakMap and WeakReference have no meaningful source shape, and the interfaces are given an implementation through MapperBuilder::infer().

Every template declares a default, so that these classes stay usable without generics, as they appear bare in countless existing signatures. Mapping to one without them now reports that mixed is not allowed in strict mode, where map(SplStack::class, []) previously returned an empty stack.

DateTime, DateTimeImmutable and DateTimeZone each had a builder factory of their own doing the same job; both are dropped, their constructors joining the list. A built-in constructor applies when no registered one takes a single argument, so customising the supported date formats no longer adds one back when a registered constructor already takes the source.

Fixes #529

romm added 2 commits August 10, 2026 22:45
A constructor declaring templates of its own, such as a factory
returning `SomeCollection<T>` out of a `list<T>`, never had `T` bound
to the generic of the type being mapped. The placeholder stayed in the
parameters of the constructor and reached the mapper, which asserted on
it, and took any value at all once assertions were disabled.

The templates are now inferred from the type the class definition
carries, the way a registered converter already has its own inferred
from the type it converts to. A parameter typed `list<T>` becomes
`list<SomeClass>` when `SomeCollection<SomeClass>` is mapped.

A constructor marked with the `Constructor` attribute needed more: the
templates a method declares for itself were collected nowhere, the
function definition repository reading them from a docblock where the
method definition builder never did. The very same method therefore
worked when given to `MapperBuilder::registerConstructor()` and failed
when marked, on `cannot parse unknown symbol T`. They are now resolved
next to the generics of the class declaring the method, both being
vacant types for that method alone.
The mapper learns about generics by reading the `@template` annotations
of a class docblock. Classes internal to PHP, or provided by an
extension, carry no such docblock and cannot be modified, so the type of
the values they hold could never be described.

Their signature is now known to the mapper, and the ones a source can be
mapped to without ambiguity are built with no configuration:

```php
final class Article
{
    public function __construct(
        public string $title,
        /** @var \Ds\Set<string> */
        public \Ds\Set $tags,
    ) {}
}

(new \CuyZ\Valinor\MapperBuilder())->mapper()->map(Article::class, [
    'title' => 'Some article',
    'tags' => ['php', 'valinor'],
]);
```

An array builds `ArrayObject`, `ArrayIterator` and `Ds\Map`, a list the
`Spl*` and `Ds\*` collections, a key and a value `Ds\Pair`. The others
are described but never built, until a constructor is registered for
them: `SplHeap` is abstract, `SplObjectStorage` is keyed by objects, the
priority queues need a priority for each value, `Generator`, `WeakMap`
and `WeakReference` have no meaningful source shape, and the interfaces
are given an implementation through `MapperBuilder::infer()`.

Every template declares a default, so that these classes stay usable
without generics, as they appear bare in countless existing signatures.
Mapping to one without them now reports that `mixed` is not allowed in
strict mode, where `map(SplStack::class, [])` previously returned an
empty stack.

`DateTime`, `DateTimeImmutable` and `DateTimeZone` each had a builder
factory of their own doing the same job; both are dropped, their
constructors joining the list. A built-in constructor applies when no
registered one takes a single argument, so customising the supported
date formats no longer adds one back when a registered constructor
already takes the source.
@romm
romm force-pushed the feat/support-internal-class-generics branch from 16c706d to d9d5f02 Compare August 10, 2026 21:48
@romm
romm merged commit 5419b44 into CuyZ:master Aug 10, 2026
16 checks passed
@romm
romm deleted the feat/support-internal-class-generics branch August 10, 2026 21:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How can I teach Valinor to understand generics of \Ds\Set?

1 participant