Re: [RFC] [Discussion] Bound-Erased Generic Types
| From: | Rob Landers | Date: | Sun, 17 May 2026 11:17:35 +0000 |
| Subject: | Re: [RFC] [Discussion] Bound-Erased Generic Types | ||
| References: | 1 2 3 4 5 6 7 | Groups: | php.internals |
| Request: | Send a blank email to internals+get-130923@lists.php.net to get a copy of this message | ||
On Sat, May 16, 2026, at 23:32, Seifeddine Gmati wrote:
> Hi Rowan,
>
> For evidence that these inconsistencies are real, you can look at the issue trackers for Mago,
> PHPStan, or Psalm. Even basic syntax varies across tools today: some use
@template T
> of X while others use @template T as X. Defaults and
> inference are also major categories of disagreement. For example, new
> Collection([1]) could be inferred as several different types
> (Collection<1>,
> Collection<int>,
> Collection<scalar>,
> Collection<mixed>,
> Collection<numeric>); as a maintainer, there isn't always
> a "correct" answer. Native syntax like new
> Collection::<int>([1]) makes user intent explicit, forcing tools to converge
> on the same answer.
>
> The disagreement isn't limited to edge cases or inference. Even fundamental correctness
> questions divide the tools. Consider this diamond pattern:
>
> ```php
> /** @template T */
> interface Example {
> /** @param T $v @return T */
> public function produce(mixed $v): mixed;
> }
>
> /** @extends Example<A> */ interface AExample extends Example {}
> /** @extends Example<B> */ interface BExample extends Example {}
>
> class X implements AExample, BExample {
> /** @param A|B $v @return A */
> #[Override]
> public function produce(mixed $v): A { /* ... */ }
> }
> ```
>
> The correct return type here is A & B. The implementer must
> satisfy both AExample::produce(A): A and
> BExample::produce(B): B, which forces the parameter to be
> A | B (LSP widening on contravariant position) and the return to be
> A & B (LSP narrowing on covariant position to the intersection).
> Anything narrower violates one parent contract: returning just A
> breaks the BExample contract; returning just
> B breaks the AExample contract.
>
> Mago and PHPStan both accept A and
> B as return types: both are wrong. Psalm rejects
> A but accepts B: also wrong, and
> inconsistently so.. Three tools exhibit three different incorrect behaviors on the same pattern.
> Under this RFC, the diamond-merge rule rejects both at compile time and forces the implementer to
> declare A & B, which is the only sound choice.
This example is well chosen but I think it’s doing more rhetorical work than the mechanism
supports, and that matters for the “forces tools to converge” claim.
If I’m reading the RFC correctly, the diamond-merge check fires at link time when a concrete class
derives from two generic parents instantiated at different type arguments. Outside that specific
structural pattern, T erases to its bound and the engine has no opinion on whether a parametric
position reads as intersection or union.
Which means Box<A&B> works either way you want it to work. It could mean A|B or A&B;
the engine happily accepts either reading. PHPStan users internalize one rule, Psalm users another,
Mago users a third, each reinforced by their tool of choice and by passing tests because the runtime
never surfaces the disagreement. They won’t know they’re “holding it wrong” until someone
writes class X implements AExample, BExample. At that point the rejection lands not on the upstream
interface author whose parametric design forced the constraint, but on the downstream implementer
trying to satisfy both contracts … possibly years after the original contract was written.
I’m not arguing the engine check is wrong; it’s a real win at the points where it does fire. But
“forces tools to converge” overstates it. The convergence is local to specific link-time
checkpoints. Between them, SA tools keep diverging on inference and parametric semantics, and
developers keep learning the type system from whichever tool they happen to use. That’s a real
dev-UX problem, IMHO.