Skip to content

Preserve callable, Closure, class-string and float-literal template bounds instead of widening them to mixed - #6483

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-jtrw4ht
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-jtrw4ht

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Callable signature types could not be used as @template bounds. @template F of Closure(int): int, @template F of callable and @template F of callable(int): int were reported as generics.notSupportedBound, the template silently degraded to mixed (which then triggered parameter.phpDocType / return.phpDocType follow-up errors against the native Closure/callable typehints), and the concrete closure type passed at the call site was lost.

The same silent widening affected class-string, class-string<T> and float-literal bounds, and the rule that reports unsupported bounds had drifted out of sync with the factory that implements them.

Changes

New Template*Type classes, each reconstructing its parent type from the bound (src/Type/Generic/):

  • TemplateClosureType@template T of Closure(int): string

  • TemplateCallableType@template T of callable, @template T of callable(int): string

  • TemplateClassStringType@template T of class-string

  • TemplateGenericClassStringType@template T of class-string<Exception>

  • TemplateConstantFloatType@template T of 1.5 (completes the constant-scalar family next to TemplateConstantIntegerType / TemplateConstantStringType)

  • src/Type/Generic/TemplateTypeFactory.php: register the five new classes so the bound is preserved instead of falling through to TemplateMixedType with a mixed bound.

  • src/Rules/Generics/TemplateTypeCheck.php: replace the hand-maintained list of supported bound classes with a question to TemplateTypeFactory — report generics.notSupportedBound exactly when the factory did not keep the declared bound.

  • phpstan-baseline.neon: regenerated for the new instanceof/toPhpDocNode entries that mirror the existing Template*Type ones, minus the entry that the TemplateTypeCheck rewrite made obsolete.

Analogous cases fixed by the rule/factory sync (the factory already preserved these bounds, only the rule falsely reported them):

  • @template T of int<0, 10> (IntegerRangeType)
  • @template T of true / @template T of false (ConstantBooleanType)
  • @template T of Suit::Hearts (EnumCaseObjectType and any other ObjectType subclass)

Probed and deliberately left unsupported: void, never (a template bounded by either can hold no value) and resource (PHPStan does not distinguish resources, so the refinement carries no information). @template T of static keeps failing earlier with class.notFound, which is a separate concern.

Root cause

TemplateTypeFactory::create() dispatches on the bound's class to pick a Template*Type implementation, and ends with a catch-all that returns new TemplateMixedType(..., new MixedType(true)). Any bound without a dedicated Template* class therefore lost its meaning entirely: inference against the bound could no longer succeed, so the template resolved to nothing and the caller fell back to the native return typehint.

TemplateTypeCheck warned about this with a second, hand-written list of accepted bound classes. Because the two lists were maintained independently, they disagreed in both directions: bounds the factory handled fine (int<0, 10>, true, enum cases) were reported as unsupported, while the list gave no protection against the factory gaining or losing support. The check now derives its answer from the factory, so the rule and the implementation cannot drift again.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15273.php — the issue's playground sample verbatim; all four calls must infer static-Closure(int): int. Fails before the fix on three of the four assertions (Closure, callable(): mixed, callable(): mixed).
  • tests/PHPStan/Analyser/nsrt/template-bound-types.php — inference through class-string, class-string<Exception>, int<0, 10> and 1.5 bounds. Fails before the fix on three of the four assertions.
  • tests/PHPStan/Rules/PhpDoc/data/bug-15273.php + IncompatiblePhpDocTypeRuleTest::testBug15273() — the @param/@return "is not subtype of native type" follow-up errors. Eight errors before the fix, none after.
  • tests/PHPStan/Rules/Generics/data/function-template.php + FunctionTemplateTypeRuleTest::testRule() — every newly supported bound plus the ones that stay unsupported. Nine generics.notSupportedBound false positives before the fix; only void and never remain.

make tests, make phpstan and make cs are green. make name-collision fails on the pre-existing unparseable PHP 8.5 data files (*-pipe.php) both with and without this change; running the detector with those excluded reports no collisions.

Fixes phpstan/phpstan#15273

…late bounds instead of widening them to `mixed`

* Add `TemplateCallableType`, `TemplateClosureType`, `TemplateClassStringType`,
  `TemplateGenericClassStringType` and `TemplateConstantFloatType`, each rebuilding
  its parent type from the bound so the template behaves like the bound it declares.
* Register the new classes in `TemplateTypeFactory::create()`, which previously fell
  through to `TemplateMixedType` with a `mixed` bound for these types.
* Derive the `generics.notSupportedBound` check in `TemplateTypeCheck` from
  `TemplateTypeFactory` instead of a hand-maintained list of bound classes. The two
  lists had drifted apart, so `int<0, 10>`, `true`/`false` and enum-case bounds were
  reported as unsupported even though the factory already preserved them.
* Adjacent bounds probed and left unsupported on purpose: `void`, `never` (a template
  bounded by them can hold no value) and `resource` (PHPStan cannot tell resources
  apart, so refining one adds nothing). They are now covered by the rule test.
@ondrejmirtes

Copy link
Copy Markdown
Member

Personally I wouldn't tackle this feature request now, seems like a crazy edge case.

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.

Callable signature types cannot be used as template bounds

2 participants