Skip to content

Do not treat mixed type arguments as compatible both ways when both generic types are parameterized - #6398

Merged
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-njhy11t
Sep 9, 2026
Merged

Do not treat mixed type arguments as compatible both ways when both generic types are parameterized#6398
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-njhy11t

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Assigning new Foo($mixed) in a try block and new Foo(123) in the corresponding catch block made PHPStan infer Foo<int> for the merged variable — the Foo<mixed> branch was silently dropped.

The union of Foo<mixed> and Foo<int> was order-dependent: Foo<mixed>|Foo<int> collapsed to Foo<mixed> while Foo<int>|Foo<mixed> collapsed to Foo<int>. GenericObjectType::isSuperTypeOf() answered yes in both directions, so TypeCombinator::union() absorbed whichever type happened to come first.

Changes

  • src/Type/Generic/TemplateTypeVariance.phpisValidVariance() gained a bool $strict = false parameter. In the default (gradual) mode nothing changes. In the strict mode the mixed / BenevolentUnionType shortcuts that answer yes in both directions are skipped; covariance and contravariance then fall through to the normal isSuperTypeOf() computation, and invariance is relaxed only in one direction (mixed on the supertype side → yes, on the subtype side → maybe).
  • src/Type/Generic/TemplateType.php, src/Type/Generic/TemplateTypeTrait.php — the new optional parameter threaded through TemplateType::isValidVariance().
  • src/Type/Generic/GenericObjectType.phpisSuperTypeOfInternal() enables the strict mode outside of the accepts context when the compared type is itself a GenericObjectType.
  • tests/PHPStan/Type/Generic/TemplateTypeVarianceTest.php — now asserts the result in both directions for both the default and the strict mode.
  • phpstan-baseline.neon — bumped the ignored phpstanApi.instanceofType count for GenericObjectType.php (3 → 4).

Analogous cases that were broken the same way and are fixed by this change (all covered by the regression test):

  • @template-covariant: CovariantFoo<int>|CovariantFoo<mixed> used to collapse to CovariantFoo<int>.
  • @template-contravariant: ContravariantFoo<mixed>|ContravariantFoo<int> used to collapse to ContravariantFoo<mixed> instead of ContravariantFoo<int>.
  • Generic interfaces reached through @implements/@extends: FooInterface<int>|FooInterface<mixed>.
  • GenericClassStringType: class-string<Foo<mixed>>|class-string<Foo<int>> used to collapse to class-string<Foo<int>>.
  • TypeCombinator::intersect(): Foo<mixed>&Foo<int> used to produce Foo<mixed> instead of Foo<int>.
  • Benevolent unions as type arguments hit the very same shortcuts and are handled identically.

Probed and found to be already order-independent, so left alone: array<mixed>/list<mixed>, iterable<mixed>, array{a: mixed} shapes, object{a: mixed} shapes, and Closure(mixed): void parameter types.

Root cause

TemplateTypeVariance::isValidVariance() short-circuited to yes whenever either of the compared type arguments was mixed (or a BenevolentUnionType), regardless of which side it was on. That is the right answer for accepts() — gradual typing means an unparameterized or mixed-parameterized value is usable wherever a specific one is expected and vice versa — but it made isSuperTypeOf() a symmetric relation.

TypeCombinator::union() decides which of two types to drop by asking isSuperTypeOf() on $a first and on $b second, so a symmetric yes means the surviving type is decided by argument order. Scope merging (if/else, try/catch, switch, loops) feeds the branch types into that union, which is why the catch branch won in the reported case.

The shortcut still has to apply when the other side is a class name written without type arguments — those are resolved to the template bounds, so their mixed means "not parameterized" rather than "the type argument is mixed". Making them strict would have broken sealed-class exhaustiveness (tests/PHPStan/Rules/Comparison/data/bug-14412.php) and None extends Option<mixed> narrowing (tests/PHPStan/Analyser/nsrt/bug-11430.php). The strict mode is therefore only turned on when the compared type carries explicit type arguments.

Test

tests/PHPStan/Analyser/nsrt/bug-15198.php contains the reproducer from the playground link (try/catch with new Foo($mixed) vs new Foo(123)) plus:

  • the same merge through if/else in both orders,
  • both orders of Foo<mixed>|Foo<int>, CovariantFoo, ContravariantFoo, FooInterface and class-string<Foo<…>> unions,
  • both orders of the Foo<mixed>&Foo<int> intersection,
  • None|Option<string> in both orders, guarding the leniency that has to stay for unparameterized class names.

Every new assertion except the last group fails without the source change. TemplateTypeVarianceTest covers the flag itself at the unit level.

Fixes phpstan/phpstan#15198

… generic types are parameterized

- `TemplateTypeVariance::isValidVariance()` gained a `$strict` flag. Without it the behaviour is unchanged: `mixed` and `BenevolentUnionType` type arguments answer "yes" in both directions, which is what keeps unparameterized class names (whose type arguments are resolved to the template bounds) compatible with parameterized ones.
- With `$strict` the gradual-typing shortcuts are skipped, so covariance and contravariance are evaluated normally, and invariance only keeps `Foo<mixed>` a supertype of `Foo<int>` while the opposite direction becomes `maybe`.
- `GenericObjectType::isSuperTypeOfInternal()` turns the strict mode on outside of the accepts context when the compared type is itself a `GenericObjectType`, i.e. when both sides carry explicit type arguments.
- This makes `isSuperTypeOf()` antisymmetric again, so `TypeCombinator::union()` and `TypeCombinator::intersect()` no longer produce a different result depending on the order of the operands.
- Analogous cases probed and fixed by the same change: `@template-covariant`, `@template-contravariant`, generic interfaces reached through `@implements`/`@extends`, `class-string<Foo<mixed>>`, and intersections. Array/iterable/callable/object-shape type arguments and unparameterized subclasses (`None extends Option<mixed>`) were probed and were already order-independent.
- Extended `TemplateTypeVarianceTest` to assert both the default and the strict result in both directions.

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add regression test for phpstan/phpstan#11935

Pins the gradual-typing leniency the strict variance mode has to keep:
`accepts()` stays symmetric for a `mixed` type argument, while
`isSuperTypeOf()` only answers `yes` when the `mixed` is on the supertype
side.

See phpstan/phpstan#11935

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed as c6be672ad.

What I added

tests/PHPStan/Rules/Functions/data/bug-11935.php + two test methods in CallCallablesRuleTest:

  • The verbatim reproducer from the issue's playground link (callable(A): A invoked with a mixed argument), plus two Inv<mixed> variants that go through GenericObjectType.
  • testBug11935() — without checkExplicitMixed nothing is reported. This is the gradual-typing leniency this PR deliberately keeps in the accepts() context.
  • testBug11935WithCheckExplicitMixed() — at level 9+ the two Inv<mixed> calls are reported.

GenericObjectTypeTest — the relation itself, both directions:

  • isSuperTypeOf: A<mixed>A<DateTime> is Yes, A<DateTime>A<mixed> is Maybe.
  • accepts: both directions stay Yes.

Verification

  • The isSuperTypeOf "mixed type arg on the sub side" case fails on the parent commit (Yes instead of Maybe) and passes with the fix — checked by restoring src/Type/Generic/ from HEAD~1 and re-running.
  • The accepts cases and both rule tests pass on the parent commit too — that is the point: they pin behaviour the change must not move.
  • make tests (21306 tests, 97 skipped, no failures), make phpstan (no errors), make cs (clean).

One thing worth flagging

Issue #11935's own snippet — plain mixed passed where template A is expected — is still not reported, at any level, both before and after this PR. That false negative comes from TemplateTypeArgumentStrategy::accepts() being bypassed when the argument becomes StrictMixedType at level 9+, which is a separate code path from the variance shortcut this PR touches. The issue is labelled feature-request and fixing it was out of scope here, so the test encodes the current behaviour (no error on line 17) rather than the behaviour the reporter asked for. If you'd rather this PR also close #11935, say so and I'll take a run at StrictMixedType/TemplateTypeArgumentStrategy.

@ondrejmirtes
ondrejmirtes merged commit adb28cf into phpstan:2.2.x Sep 9, 2026
487 of 493 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-njhy11t branch September 9, 2026 12:49
@staabm

staabm commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

did this PR close phpstan/phpstan#11935 ?

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.

3 participants