Skip to content

Resolve ConditionalType when subject-target relationship is deterministic despite containing template types#5631

Merged
VincentLanglet merged 7 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-vd4m3b6
May 10, 2026
Merged

Resolve ConditionalType when subject-target relationship is deterministic despite containing template types#5631
VincentLanglet merged 7 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-vd4m3b6

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 10, 2026

Summary

When a function with a conditional return type like @return (T is string ? string : T) is called with a narrowed template type argument (e.g., T of string after an is_string() check), PHPStan incorrectly reports "Return type of call to function ... contains unresolvable type." This is a false positive — the conditional return type is fully deterministic in this case.

Changes

  • Modified ConditionalType::isResolvable() in src/Type/ConditionalType.php to check the actual subject-target type relationship when template types are present. If target->isSuperTypeOf(subject) returns a definitive yes() or no(), the conditional is considered resolvable.
  • Added regression test data at tests/PHPStan/Rules/Functions/data/bug-11894.php (function calls with conditional return types, including negated conditionals)
  • Added regression test data at tests/PHPStan/Rules/Methods/data/bug-11894.php (method calls and static method calls — same pattern, different rule)
  • Added NSRT test at tests/PHPStan/Analyser/nsrt/bug-11894.php to verify type inference produces correct results for narrowed, non-matching, and partially-narrowable template types
  • Added test methods in CallToFunctionParametersRuleTest, CallMethodsRuleTest, and CallStaticMethodsRuleTest

Root cause

ConditionalType::isResolvable() unconditionally returned false when the subject or target contained a template type. This prevented resolution of the conditional type during resolveLateResolvableTypes(false) in ResolvedFunctionVariantWithOriginal::getReturnType(). The unresolved ConditionalType then had its branches traversed by UnresolvableTypeHelper, which found an implicit never in the normalized else branch — a natural result of TypeCombinator::remove(T of string, string) producing never — and incorrectly flagged it as an unresolvable type.

The fix makes isResolvable() smarter: even with template types present, if the subject-target supertype relationship is deterministic (yes or no), the conditional can be resolved. For example, string->isSuperTypeOf(T of string) is always yes (the template's bound guarantees it), so the conditional resolves to the if branch (string) and the problematic never in the else branch is never reached.

Analogous cases probed

  • Method calls: same pattern via CallMethodsRule — tested and confirmed fixed by the same ConditionalType::isResolvable() change
  • Static method calls: same pattern via CallStaticMethodsRule — tested and confirmed fixed
  • Negated conditional types (T is not string ? T : string): tested, works correctly
  • Non-matching narrowing (e.g., T of int passed to T is string ? ...): confirmed the conditional correctly resolves to the else branch (no() path)
  • Partially narrowable types (e.g., T of string|int with target string): confirmed these correctly remain unresolvable (maybe result)
  • Existing legitimate unresolvable type tests (generic-return-type-never.php): confirmed still passing — the fix does not suppress real unresolvable type errors

Test

  • tests/PHPStan/Rules/Functions/data/bug-11894.php + CallToFunctionParametersRuleTest::testBug11894 — function calls with narrowed template types and conditional return types (including negated variants)
  • tests/PHPStan/Rules/Methods/data/bug-11894.php + CallMethodsRuleTest::testBug11894 + CallStaticMethodsRuleTest::testBug11894 — method and static method equivalents
  • tests/PHPStan/Analyser/nsrt/bug-11894.php — type inference verification for narrowed, non-matching, and partially-narrowable template type arguments

Fixes phpstan/phpstan#11894
Fixes phpstan/phpstan#8048

…nistic despite containing template types

- `ConditionalType::isResolvable()` previously returned `false` whenever
  the subject or target contained a template type, even when the type
  relationship was deterministic (e.g. `T of string` is always a subtype
  of `string`).
- This caused false positive "contains unresolvable type" errors when
  calling functions/methods with conditional return types like
  `(T is string ? string : T)` and passing a narrowed template type
  argument.
- The fix checks `target->isSuperTypeOf(subject)`: if the result is
  `yes()` or `no()`, the conditional is resolvable regardless of
  template types in subject/target.
- Added regression tests for function calls, method calls, and static
  method calls, plus NSRT tests for type inference correctness.
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.

Exclude nsrt test from PHP 7.4 with the // lint comment

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.

Add non regression test for phpstan/phpstan#8048

phpstan-bot and others added 2 commits May 10, 2026 13:03
The test uses `mixed` type hint which requires PHP 8.0+.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ensures template type T with class-string parameter is correctly
resolved and does not produce false "Unable to resolve the template
type T" errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Both review comments addressed and pushed:

  1. NSRT lint comment: Added <?php // lint >= 8.0 to tests/PHPStan/Analyser/nsrt/bug-11894.php since the test uses mixed type hints which require PHP 8.0+.

  2. Non-regression test for #8048: Added tests/PHPStan/Rules/Methods/data/bug-8048.php with a test case using @template T of CustomResponseInterface with @param class-string<T> and @return T|null, plus the corresponding test method in CallMethodsRuleTest. This confirms our ConditionalType::isResolvable() change doesn't introduce false negatives for the "Unable to resolve the template type T" check.

Both make phpstan and make tests (12042 tests) pass cleanly.

The previous test didn't match the playground reproducer — it used
@return T|null instead of the conditional return type that triggers
the bug. Replace with the actual reproducer: a conditional return type
($responseType is class-string<T> ? T : null) called with null.

Add NSRT test verifying that request(null) resolves to null and
request(CustomResponse::class) resolves to CustomResponse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's what I did:

  1. Replaced the bug-8048 test data with the actual reproducer from the playground link in issue #8048. The previous version used @return T|null which doesn't exercise the conditional return type path that triggers the bug. The correct reproducer uses @return ($responseType is class-string<T> ? T : null) with a ?string $responseType = null parameter and calls it with null.

  2. Added NSRT test (tests/PHPStan/Analyser/nsrt/bug-8048.php) verifying that request(null) resolves to null and request(CustomResponse::class) resolves to CustomResponse.

@VincentLanglet VincentLanglet requested a review from staabm May 10, 2026 14:24
@VincentLanglet VincentLanglet self-assigned this May 10, 2026

$isSuperType = $this->target->isSuperTypeOf($this->subject);

return $isSuperType->yes() || $isSuperType->no();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

mutation testing suggests we miss tests for this line when $isSuperType is maybe

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Pushed. Here's what I did to address staabm's review comment:

  1. Added NSRT test for maybe path (tests/PHPStan/Analyser/nsrt/bug-11894.php): A multi-template class method with @return (TArray is non-empty-array ? non-empty-list<TKey> : list<TKey>) called with a non-empty-array<int, int> argument. This verifies the correct type non-empty-list<int> is inferred. If isResolvable() incorrectly returned true for the maybe case, the conditional would resolve prematurely to the union of both branches (list<int>) — this test catches that mutation.

  2. Added rule test data for maybe cases: Added testMaybeRelation (functions) and testMaybeMethod/testMaybeStaticMethod (methods) that call the conditional return functions with T of string|int. These verify no false "unresolvable type" errors are reported for the maybe path, since TypeCombinator::remove(T of string|int, string) produces int (not never).

phpstan-bot and others added 3 commits May 10, 2026 16:20
…able()

Mutation testing showed the `$isSuperType->no()` branch was not
specifically covered. Added:

- NSRT test with a multi-template conditional return type where
  premature resolution (incorrectly treating `maybe` as resolvable)
  would collapse `non-empty-list` to `list`
- Rule test data for `maybe` cases (T of string|int with target string)
  verifying no false "unresolvable type" errors are reported

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet VincentLanglet merged commit ca00199 into phpstan:2.1.x May 10, 2026
656 of 660 checks passed
@VincentLanglet VincentLanglet deleted the create-pull-request/patch-vd4m3b6 branch May 10, 2026 17:48
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