Skip to content

Resolve decided ConditionalType and ConditionalTypeForParameter when inheriting PHPDoc from a parent - #6298

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-23unhap
Open

Resolve decided ConditionalType and ConditionalTypeForParameter when inheriting PHPDoc from a parent#6298
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-23unhap

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A conditional type declared on a generic parent (@return (T is int ? string : int)) was inherited verbatim by a subclass that fixes the template argument (@extends A<string>). The subject was substituted to string, but the type stayed a ConditionalType, so MethodConditionalReturnTypeRule reported Conditional return type uses subject type string which is not part of PHPDoc @template tags. on a method that has no PHPDoc of its own.

The fix simplifies conditional types while the PHPDoc is being inherited: once the parent's template types are substituted, a condition that has become decided is replaced by its branch (recursively, so nested conditionals collapse too).

Changes

  • src/PhpDoc/ResolvedPhpDocBlock.php
    • New simplifyConditionalTypes() pass, run from resolveTemplateTypeInTag() right after TemplateTypeHelper::resolveTemplateTypes(). Because resolveTemplateTypeInTag() is used only for tags taken over from a parent, PHPDoc written on the method/property itself is untouched and still reported on as before.
    • New containsStaticType() helper guarding the simplification.
    • The merged @param tags are passed down to mergeReturnTags(), mergeParamOutTags(), mergeParamClosureThisTags() and mergeAssertTags() so ConditionalTypeForParameter can be resolved against the inherited parameter type. Inherited @param tags themselves are simplified in a second pass, once every parameter type is known.

Cases fixed (each has a failing-before test):

  • @return (T is int ? string : int) inherited from a generic parent class — the reported bug.
  • the same inherited from a generic interface (@implements).
  • the same inherited from a generic abstract trait method (@use).
  • the same inherited through a multi-level chain (Leaf extends Mid extends Base<string>).
  • @return ($val is int ? string : int)ConditionalTypeForParameter, which reported Condition "string is int" in conditional return type is always false. instead.
  • negated conditions ((T is not int ? ...)).
  • conditional types nested inside another type (list<(T is int ? string : int)>).
  • conditional types in @param-out and in @param.

Probed and found already correct, so no change and no test kept:

  • @method tags with a conditional return type on a generic class — those are not checked by the rule and were already resolved correctly.
  • @self-out — not template-resolved during inheritance at all today, and not reachable from the conditional-return-type rules.

Root cause

TemplateTypeHelper::resolveTemplateTypes() traverses into ConditionalType::traverse(), which rebuilds the conditional type with the substituted subject but never asks whether the condition has become decidable. The type therefore stays a ConditionalType whose subject is a plain string, which is exactly the shape ConditionalReturnTypeRuleHelper flags as "subject is not a template type". The user-visible error was attributed to the subclass even though the PHPDoc lives on the parent.

The same pattern applies to ConditionalTypeForParameter: substituting @param T $val to @param string $val decides ($val is int ? ...), and the rule then reports an always-false condition on the subclass.

Two things must not be simplified, and both are guarded:

  • a subject containing static/$this, because the late static bound type is only known at the call site — resolving it in the subclass would lose narrowing for a grandchild class that starts satisfying the condition;
  • a ConditionalTypeForParameter whose condition is undecided by the declared parameter type, because it still has to be resolved against the actual argument type at the call site.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15128.php — the playground reproducer plus subclasses that keep the condition undecided (@extends A<int|string>) or stay generic (@extends A<U>); asserts the inferred types are unchanged, including per-call-site resolution of ConditionalTypeForParameter.
  • tests/PHPStan/Rules/PhpDoc/MethodConditionalReturnTypeRuleTest::testBug15128() — analyses the reproducer with the rule and expects no errors. Fails without the fix with the reported conditionalType.subjectNotFound error.
  • tests/PHPStan/Rules/PhpDoc/data/conditional-return-type-inheritance.php + testConditionalReturnTypeInheritance() — covers the interface, trait, multi-level-chain, negated, nested, @param-out, @param and ConditionalTypeForParameter variants. Fails without the fix with 8 errors.
  • tests/PHPStan/Analyser/nsrt/conditional-type-inherited-static-subject.php — locks in that a static subject keeps being resolved per call site; removing the containsStaticType() guard makes it fail.

Fixes phpstan/phpstan#15128

…hen inheriting PHPDoc from a parent

* `ResolvedPhpDocBlock::resolveTemplateTypeInTag()` now runs a new `simplifyConditionalTypes()` pass after `TemplateTypeHelper::resolveTemplateTypes()`, so a conditional type that became decided by substituting the parent's `@template` types with the `@extends`/`@implements`/`@use` arguments is replaced by its result.
* `ConditionalType` is only resolved when `isResolvable()` and its subject contains no `StaticType`/`ThisType` — the late static bound type is known only at the call site, so resolving it eagerly would lose narrowing in grandchild classes.
* `ConditionalTypeForParameter` is resolved against the merged `@param` type, but only when the target type is decidedly a supertype (or decidedly not) of the parameter type; an undecided condition is left alone so it can still be resolved against the actual argument type at the call site.
* The merged `@param` tags are threaded into the merging of `@return`, `@param-out`, `@param-closure-this` and `@phpstan-assert`, and inherited `@param` tags get a second pass once all parameter types are known.
* Same fix therefore applies to all inherited tags resolved through `resolveTemplateTypeInTag()`: `@var` (properties and class constants), `@param`, `@param-out`, `@param-closure-this`, `@return` and `@phpstan-assert`, and to inheritance through parent classes, interfaces and abstract trait methods.
* Probed and found already correct: `@method` tags with conditional return types on generic classes, and call-site resolution of `ConditionalTypeForParameter` when the parameter type leaves the condition undecided.
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.

Conditional return type uses subject type string which is not part of PHPDoc @template tags.

1 participant