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.

Reply via email to