Widen inferred template type arguments that only refine one of the bound's alternatives - #6146
Widen inferred template type arguments that only refine one of the bound's alternatives#6146phpstan-bot wants to merge 2 commits into
Conversation
staabm
left a comment
There was a problem hiding this comment.
we get a failing test in https://github.com/phpstan/phpstan-src/actions/runs/30531943199/job/90837179166?pr=6146
I think when parameters are optional and not passed in, we should take their default value into account
|
Pushed as What the failing test wasThe linked job was
DiagnosisYour hunch was right about where it goes wrong, and PHPStan does already infer from the default value — The culprit was a shortcut in FixDropped the shortcut ( Tests updated in Verified the new assertions fail without the change ( One note on the run you linkedThe same run also failed |
…und's alternatives
- `TemplateTypeHelper::generalizeInferredTemplateType()` now widens a non-covariant
template type argument to the bound alternative it merely refines, so
`new LanguageProperty(['abc'])` with `@template Value of string|list<string>`
is inferred as `LanguageProperty<list<string>>` instead of `LanguageProperty<array{string}>`.
- Widening only happens when the inferred type says nothing more than the bound
alternative: array shapes are compared key type to key type and value type to value
type, everything else is compared against its `lessSpecific` generalization. Bounds
that are more general than the argument (`@template T`, `@template T of array`,
`@template T of array<string, mixed>`) keep the precise type, and object types keep
their class.
- The same widening also covers accessory-refined scalars reported in the issue
comments: `non-falsy-string` inferred for a `string|list<string>` bound now becomes
`string`.
- Analogous cases fixed by the same change (each covered by a test): static factory
methods returning `self<T>`, generic functions returning `C<T>`, nested list bounds,
`iterable<int, string>` bounds, `non-empty-list` and integer-range refinements, empty
arrays, and array-map bounds. The property/static-property/argument/return rule errors
all disappear because they share the inference root cause.
array{} carries information no array bound does - it says the array is
empty - so widening it to a bound alternative throws that away. It also
made the empty case inconsistent with every other array argument: for
@template TData of array, new ArrayStruct(['a' => 1]) kept
array{a: int} while new ArrayStruct([]) became array.
This showed up in shopware, where an optional array $data = [] parameter
left ArrayStruct<array> where the class author had declared
@extends StoreApiResponse<ArrayStruct<array{}>>.
Dropping the shortcut lets the regular key-to-key/value-to-value
comparison handle empty arrays: their never key and value types are more
precise than the bound's, so array{} is preserved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9ae47d1 to
d17ad29
Compare
|
@jiripudil could you double check this makes sense to you from a generics point of view? |
Summary
With
@template Value of string|list<string>,new LanguageProperty(['abc'])was inferred asLanguageProperty<array{string}>. Because the template type is invariant, assigning it to aLanguageProperty<list<string>>property was reported as an error, even thougharray{string}says nothing more thanlist<string>does. The same happened for the case reported in the issue comments:new LanguageProperty('abc' . doFoo())was inferred asLanguageProperty<non-falsy-string>and rejected byLanguageProperty<string>.The fix widens an inferred template type argument to the bound alternative it merely refines, so the inferred generic type is usable in the invariant positions the class author declared.
Changes
src/Type/Generic/TemplateTypeHelper.php:generalizeInferredTemplateType()was restructured (the early return for scalar bounds keeps the previous behaviour of preserving literals for@template T of string) and now finishes by calling the newwidenToBoundType().widenToBoundType()picks the single alternative of the bound that the inferred type only refines and returns it. It bails out when several alternatives match, and when the inferred type is already at least as general as the alternative (so, for example, an implicitmixedis not replaced by the bound's explicitmixed).onlyRefinesType()decides whether the inferred type says the same thing as a bound alternative with more precision. Arrays are compared key type to key type and value type to value type (recursively, so nested shapes work); everything else is compared against itsGeneralizePrecision::lessSpecific()generalization. An array that is definitely empty refines any array/iterable bound.tests/PHPStan/Analyser/nsrt/bug-15027.php— new type inference test.tests/PHPStan/Rules/Properties/data/bug-15027.php+testBug15027()intests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php— new rule test for the reported false positive on instance and static properties.Root cause
generalizeInferredTemplateType()only generalized constant values ('abc'→string,array{'abc'}→array{string}with the shape kept). Anything else - array shapes, accessory string types, integer ranges, non-empty arrays - was passed through unchanged. For an invariant template type, any such refinement makes the resulting generic type incompatible with the type the class author declared in the bound, which is what users write in their@var/@param/@returnPHPDocs. The pattern therefore showed up in every rule that compares generic types:assign.propertyType(the reported case) and the static-property variantargument.typewhen passing the object to a function/methodreturn.typewhen returning itAll of them share the single inference site, so widening at inference fixes the whole family. Both callers of
generalizeInferredTemplateType()benefit:PHPStan\Analyser\ExprHandler\NewHandler(new C(...),new static(...)) andPHPStan\Reflection\ResolvedFunctionVariantWithOriginal(static factory methods returningself<T>, generic functions returningC<T>).Deliberately not widened, because the bound says less than the argument does and the precision is the point (all covered by assertions in the new test):
@template T(mixedbound) —new Unbounded(['abc'])staysUnbounded<array{string}>@template T of array—new BoundToArray(['abc'])staysBoundToArray<array{string}>@template T of array<string, mixed>— array shapes are preserved (this is whatnsrt/bug-7788.phprelies on forT[K]offset access)@template T of \DateTimeInterface|stringkeepsDateTimeImmutable, and a\Traversablebound keepsArrayIterator@template-covarianttemplates are untouched, as beforeTest
tests/PHPStan/Analyser/nsrt/bug-15027.phpreproduces the playground sample from the issue and asserts the inferred generic types. Beyond the reported case it covers the analogous constructs probed along the "invariant template argument inferred too narrowly" axis:non-falsy-stringandnumeric-stringarguments for astring|list<string>bound →LanguageProperty<string>LanguageProperty::create()returningself<T>and a generic function returningLanguageProperty<T>@template T of list<string>(non-union bound),@template T of string|list<list<string>>(nested lists),@template T of iterable<int, string>,@template T of int|list<int>(integer range refinement),@template T of array<string, string>non-empty-list<string>and empty array argumentsmixed,array,array<string, mixed>, object bounds)tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest::testBug15027()analyses the reported snippet and expects no errors for the instance property (array{string}case) and the static property (non-falsy-stringcase).Both tests were verified to fail before the fix (with exactly the messages from the issue) and to pass after it. The full test suite, the
execintegration group and PHPStan's self-analysis are green, and self-analysis run time is unchanged.Fixes phpstan/phpstan#15027