Skip to content

Check inner union types before delegating to isSubTypeOf for LateResolvableType in UnionType::isSuperTypeOf()#5645

Merged
staabm merged 3 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-4xbv9i3
May 15, 2026
Merged

Check inner union types before delegating to isSubTypeOf for LateResolvableType in UnionType::isSuperTypeOf()#5645
staabm merged 3 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-4xbv9i3

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When a child method overrides a parent method with the exact same conditional parameter type in a union (e.g., string|($x is int ? float : bool)), PHPStan incorrectly reported "should be contravariant" for parameters and "should be covariant" for return types. The issue was that UnionType::isSuperTypeOf() did not give the inner types a chance to directly match LateResolvableType members before delegating to the resolve-based isSubTypeOf() path.

Changes

  • Modified UnionType::isSuperTypeOf() in src/Type/UnionType.php to check inner types first when comparing against a LateResolvableType && CompoundType. If any inner type directly confirms it is a supertype (e.g., a matching ConditionalTypeForParameter comparing via its $type instanceof self branch), the method returns Yes immediately. Otherwise it falls back to the existing isSubTypeOf() delegation.
  • Added regression test in tests/PHPStan/Rules/Methods/data/bug-10942.php covering both parameter contravariance and return type covariance with conditional types in unions.

Root cause

UnionType::isSuperTypeOf() had an early return for LateResolvableType && CompoundType types that immediately delegated to $otherType->isSubTypeOf($this). This method (from LateResolvableTypeTrait) resolves the conditional type to the union of its if/else branches, then checks if that resolved type fits the target. For non-resolvable types like ConditionalTypeForParameter, the result is downgraded to at most Maybe via $isSuperType->and(IsSuperTypeOfResult::createMaybe()).

The inner type loop (which would have found a matching ConditionalTypeForParameter via the if ($type instanceof self) branch returning Yes) was never reached because the early return ran first.

The fix separates the LateResolvableType case from the other early returns, first trying the inner type loop to find direct matches before falling back to isSubTypeOf.

Analogous cases probed

  • ConditionalType (template-based conditionals): Has the same isSuperTypeOf structure with $type instanceof self branch. The fix covers this case too, though it's less likely to appear in practice because TemplateTypeHelper::resolveToBounds() usually makes ConditionalType resolvable before the comparison.
  • Other LateResolvableType implementations (KeyOfType, ValueOfType, OffsetAccessType, NewObjectType, ClassConstantAccessType, GetTemplateTypeType): These use the default isSuperTypeOfDefault() from the trait without a same-type shortcut. The fix still tries them first but they'd return Maybe for non-resolvable cases. In practice, these types are typically resolvable after resolveToBounds(), so the issue doesn't manifest.
  • IntersectionType::isSuperTypeOf(): Does not have the same early return pattern for LateResolvableType — it uses extremeIdentity over inner types directly, so it's not affected.
  • Return type covariance: The same isSuperTypeOf() is used for both parameter contravariance and return type covariance checks, so the fix addresses both directions.

Test

  • tests/PHPStan/Rules/Methods/data/bug-10942.php: Tests that overriding a method with identical string|($x is int ? float : bool) parameter and return types produces no errors. Verified the test fails without the fix (reports both contravariance and covariance errors).

Fixes phpstan/phpstan#10942

Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

needs another test which reflects 1:1 the snippet from phpstan/phpstan#10942

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. I updated the test to include the exact snippet from the issue's playground link as the primary test case (classes A and B with _renderConditionBinary and inherited conditional param type), while keeping the previous explicit-PHPDoc variant as a secondary case (classes C and D). Both make tests and make phpstan pass. The commit has been pushed.

@staabm staabm requested a review from VincentLanglet May 12, 2026 05:41
VincentLanglet and others added 3 commits May 13, 2026 20:15
…esolvableType` in `UnionType::isSuperTypeOf()`

- When a `ConditionalTypeForParameter` (or other `LateResolvableType`) was inside a
  `UnionType`, `isSuperTypeOf()` immediately delegated to `isSubTypeOf()`, which
  resolved the conditional and lost precision (anding with Maybe for non-resolvable types)
- Now the inner types of the union are checked first via `isSuperTypeOf()`, which allows
  matching conditional types to find each other directly (via the `$type instanceof self`
  branch in `ConditionalTypeForParameter::isSuperTypeOf()`) before falling back to the
  resolve-based `isSubTypeOf()` path
- This fixes false positive "should be contravariant/covariant" errors when a child method
  overrides a parent method with the exact same conditional parameter/return type in a union
The existing test used a simplified snippet. Add the exact code from
the issue's playground link (with inherited conditional param type and
no repeated PHPDoc in the child class) as the primary test case, and
keep the original explicit-PHPDoc variant as a secondary case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-4xbv9i3 branch from b900e03 to 559d805 Compare May 13, 2026 18:15
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Without changing the code, explain in a github comment, step by step how the type method are used/resolved in order to produce the false positive should be contravariant and why this change fix it.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The token doesn't have write permissions to the upstream phpstan/phpstan-src repo. The reviewer asked for an explanation as a GitHub comment, but I can't post there. Since the instructions say "The results of this prompt will be posted to the pull request in a separate step," I should provide the explanation here so it can be posted.

No code changes are needed — the reviewer explicitly said "without changing the code, explain in a github comment." Here's the explanation:


@VincentLanglet Here is the step-by-step walkthrough of how the false positive was produced and why this change fixes it.


Setup

Consider the reproducer from the issue: class A declares a method with parameter type string|($operator is 'in' ? int : never), and class B extends A overriding the same method — inheriting the exact same PHPDoc conditional parameter type.

Step 1: How the variance check starts

MethodSignatureRule::checkParameterTypeCompatibility() (line 217 of MethodSignatureRule.php) checks parameter contravariance by calling:

$parameterType->isSuperTypeOf($parentParameterType)

Both $parameterType (child) and $parentParameterType (parent) go through TemplateTypeHelper::resolveToBounds() first, which only strips TemplateType — it leaves ConditionalTypeForParameter untouched. So both sides are:

UnionType(StringType, ConditionalTypeForParameter($operator is 'in' ? int : never))

The call becomes: childUnion->isSuperTypeOf(parentUnion).

Step 2: UnionType::isSuperTypeOf() dispatches parentUnion

Since $otherType (the parent) is a UnionType, the condition at line 268 matches:

if ($otherType instanceof self && !$otherType instanceof TemplateUnionType) {
    return $otherType->isSubTypeOf($this);
}

This calls parentUnion->isSubTypeOf(childUnion).

Step 3: UnionType::isSubTypeOf() iterates inner types

UnionType::isSubTypeOf() (line 296-299) checks each inner type of the parent union against the child union:

return IsSuperTypeOfResult::extremeIdentity(
    ...array_map(fn (Type $innerType) => $otherType->isSuperTypeOf($innerType), $this->types)
);

This makes two calls:

  1. childUnion->isSuperTypeOf(StringType)Yes (the child union contains StringType)
  2. childUnion->isSuperTypeOf(ConditionalTypeForParameter) → this is where the bug lives

Step 4: UnionType::isSuperTypeOf() with a ConditionalTypeForParameter — the bug

Now $otherType is a ConditionalTypeForParameter, which implements both LateResolvableType and CompoundType, and is not a TemplateType.

Before the fix, the early-return block at line 268 included:

|| ($otherType instanceof LateResolvableType && $otherType instanceof CompoundType && !$otherType instanceof TemplateType)

This matched, so PHPStan immediately returned $otherType->isSubTypeOf($this) — delegating to LateResolvableTypeTrait::isSubTypeOf() without ever trying the inner type loop.

Step 5: LateResolvableTypeTrait::isSubTypeOf() resolves and loses precision

LateResolvableTypeTrait::isSubTypeOf() (line 639) calls $this->resolve(). For ConditionalTypeForParameter, resolve() calls getResult() which returns TypeCombinator::union($this->if, $this->else) — i.e., int|never = IntegerType.

Since IntegerType is not a CompoundType, it falls to:

return $otherType->isSuperTypeOf($result);  // childUnion->isSuperTypeOf(IntegerType)

The child union string|ConditionalTypeForParameter(...) checks: is StringType a supertype of IntegerType? No. Is ConditionalTypeForParameter a supertype of IntegerType? This calls isSuperTypeOfDefault() (line 65 of LateResolvableTypeTrait.php), which resolves again to IntegerType, gets IntegerType->isSuperTypeOf(IntegerType) = Yes, but then:

if (!$this->isResolvable()) {
    $isSuperType = $isSuperType->and(IsSuperTypeOfResult::createMaybe());
}

ConditionalTypeForParameter::isResolvable() always returns false (line 128), so the Yes is downgraded to Maybe.

Step 6: The result propagates as Maybe → false positive

Back in step 4, the early return gives Maybe for childUnion->isSuperTypeOf(ConditionalTypeForParameter).

Back in step 3, extremeIdentity(Yes, Maybe) = Maybe.

Back in step 1, the parameter result is Maybe, and since $this->reportMaybes is true, MethodSignatureRule reports: "should be contravariant".

The same flow applies to return type covariance (step is parentReturnType->isSuperTypeOf(childReturnType) at line 183), producing the "should be covariant" error.

Why the fix works

After the fix, the LateResolvableType && CompoundType condition is removed from the early-return block. The inner type loop now runs first:

foreach ($this->types as $innerType) {
    $result = $innerType->isSuperTypeOf($otherType);
    if ($result->yes()) {
        return $result;
    }
    $results[] = $result;
}

When $innerType is the child's ConditionalTypeForParameter and $otherType is the parent's ConditionalTypeForParameter, this calls ConditionalTypeForParameter::isSuperTypeOf() (line 77), which has:

if ($type instanceof self) {
    return $this->if->isSuperTypeOf($type->if)
        ->and($this->else->isSuperTypeOf($type->else));
}

Since both conditionals have identical if and else branches, this returns Yes.and(Yes) = Yes. The loop short-circuits, UnionType::isSuperTypeOf() returns Yes, and no error is reported.

The LateResolvableType delegation is moved to after the inner type loop (line 288-291), so it still serves as a fallback — if no inner type directly matched, the resolved form is tried via $result->or($otherType->isSubTypeOf($this)). This preserves the existing behavior for cases where the conditional type genuinely doesn't match any inner type.


I wasn't able to post this comment directly to the PR — the token doesn't have write access to phpstan/phpstan-src. The explanation above is ready to be posted as-is. No code changes were requested in this review.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Shouldn't the fix be inside LateResolveTypeTrait instead ?

Let's have a ConditionalType,
ConditionalType(...) should be subTypeOf Union(ConditionalType(...), Foo)

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All 12,062 tests pass and make phpstan reports no errors. No code changes are needed — the reviewer's comment is a design question.

Here's the summary for the PR response:


The fix can't be moved to LateResolvableTypeTrait::isSubTypeOf() because of the double-dispatch recursion inherent in the CompoundType protocol.

Why the trait can't call $otherType->isSuperTypeOf($this) (unresolved):

Every type that uses the standard CompoundType dispatch pattern — JustNullableTypeTrait (used by StringType, IntegerType, FloatType, BooleanType, etc.) — has this in isSuperTypeOf():

if ($type instanceof CompoundType) {
    return $type->isSubTypeOf($this);
}

So CTFP->isSubTypeOf(StringType)StringType->isSuperTypeOf(CTFP)CTFP->isSubTypeOf(StringType) → infinite loop. This happens with any non-compound type, not just UnionType.

Why a $otherType instanceof UnionType check (like IntersectionType::isSubTypeOf() does) doesn't help either:

That approach requires removing the LateResolvableType early return from UnionType (otherwise the early return and the new check loop). But once the early return is removed, UnionType::isSuperTypeOf() goes through its inner type loop, and each inner type dispatches to CTFP->isSubTypeOf(innerType) — NOT CTFP->isSubTypeOf(UnionType). So the UnionType check in the trait is never reached from this path, and non-matching cases (where the resolved form should be compared against the full union) aren't handled.

Why UnionType::isSuperTypeOf() is the right place:

It's the only place that can both:

  1. Iterate inner types first to find structural matches (CTFP-to-CTFP via $type instanceof self returning Yes)
  2. Fall back to $otherType->isSubTypeOf($this) after the inner type loop for cases where no inner type directly matches

This is the same pattern already used for TemplateUnionType on the same line.

@staabm staabm merged commit 6147f91 into phpstan:2.1.x May 15, 2026
1220 of 1235 checks passed
@staabm staabm deleted the create-pull-request/patch-4xbv9i3 branch May 15, 2026 15:09
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