Preserve TemplateType in ArrayType and IntersectionType array-mutating methods#5698
Conversation
| return $this->intersectTypes(static function (Type $type) use ($otherArraysType): Type { | ||
| if ($type instanceof TemplateType) { | ||
| return $type; | ||
| } | ||
| return $type->intersectKeyArray($otherArraysType); | ||
| }); |
There was a problem hiding this comment.
extract and re-use a intersectTypesPreserveTemplateType method
There was a problem hiding this comment.
Extracted a intersectTypesPreserveTemplateType() private helper method that wraps the callback with the TemplateType instanceof check, then replaced all 10 unconditional occurrences of that pattern with one-liner calls to the new helper. The 2 conditional cases (getValuesArray and shuffleArray which check $isList first) remain using intersectTypes directly since they have different logic. Tests and static analysis both pass cleanly.
| return $this->intersectTypes(static fn (Type $type): Type => $type->getValuesArray()); | ||
| $isList = $this->isList()->yes(); | ||
| return $this->intersectTypes(static function (Type $type) use ($isList): Type { | ||
| if ($isList && $type instanceof TemplateType) { |
There was a problem hiding this comment.
why do we need this only for lists?
There was a problem hiding this comment.
Both review comments are addressed:
-
"Extract and re-use a
intersectTypesPreserveTemplateTypemethod" — Already pushed as commitcf2f90d24. The repeatedif ($type instanceof TemplateType) { return $type; }closures in 10 methods are now routed through a singleintersectTypesPreserveTemplateType()helper. -
"Why do we need this only for lists?" (
getValuesArray()) — The list-only guard is correct and intentional.array_values()re-indexes keys to sequential integers (0, 1, 2, ...). For a list, this is a no-op — the keys are already sequential integers — so preserving the template type T is safe. For a non-list likeT & array<string, int>,array_values()fundamentally changes the key structure (string keys become integer keys), making the result incompatible with T. The same reasoning applies toshuffleArray(), which also has the$isListguard. The other methods that useintersectTypesPreserveTemplateTypeunconditionally (popArray,shiftArray,reverseArray, etc.) only remove or reorder elements without changing key types, so T remains valid.
There was a problem hiding this comment.
add tests expecting these methods only preserve their template types on lists
There was a problem hiding this comment.
Pushed. Added 4 new test cases in bug-14633.php that verify the list-only guard on getValuesArray() and shuffleArray():
arrayValuesOnNonList:array_values()onT & array<string, int>returnslist<int>(T dropped — keys change from string to int)shuffleOnList:shuffle()onT & list<int>preserves T (keys are already sequential integers)shuffleOnNonList:shuffle()onT & array<string, int>returnslist<int>(T dropped — keys change)- The existing
arrayValuesOnListtest already covered the list-preserving case
All tests (12100) and make phpstan pass cleanly.
…utating methods - In `ArrayType`, use `$this->withTypes()` instead of `new self()` / `new ArrayType()` in `intersectKeyArray()`, `sliceArray()`, `spliceArray()`, `mapValueType()`, `mapKeyType()`, `changeKeyCaseArray()`, and `filterArrayRemovingFalsey()` so that `TemplateArrayType` preserves its template identity through these operations. - In `IntersectionType`, skip calling array methods on `TemplateType` members (return the template unchanged) in `popArray()`, `shiftArray()`, `reverseArray()`, `sliceArray()`, `spliceArray()`, `intersectKeyArray()`, `mapValueType()`, `mapKeyType()`, `changeKeyCaseArray()`, `filterArrayRemovingFalsey()`, and `getValuesArray()` (conditional on list-ness for the latter). - In `ArrayFilterFunctionReturnTypeHelper`, call `filterArrayRemovingFalsey()` directly on the original type when it is purely an array, preserving intersection context (and thus template types) instead of decomposing via `getArrays()`.
…nType` Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tValuesArray/shuffleArray For array_values() and shuffle() on non-list arrays (e.g. T & array<string, int>), the template type T must NOT be preserved because keys change from string to int. On lists, T is correctly preserved since the key structure is unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rray` and `shuffleArray` Instead of inlining the TemplateType check inside the closure, branch on `$isList` and call either `intersectTypesPreserveTemplateType` or `intersectTypes` directly. This is cleaner and consistent with how the other methods use the helper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
354a929 to
212a920
Compare
| $arrayArgType = $scope->getType($arrayArg); | ||
| $arrayArgType = TypeUtils::toBenevolentUnion($arrayArgType); | ||
| $originalArrayArgType = $scope->getType($arrayArg); | ||
| $arrayArgType = TypeUtils::toBenevolentUnion($originalArrayArgType); |
There was a problem hiding this comment.
Is there existing test for the toBenevolentUnion behavior strategy in ArrayFilterFunctionReturnTypeHelper which is not in some other returnTypeExtension ?
Cause we end up different behavior for array_filter than for other function see
public function startDay(array|object $task): string
{
dumpType(array_chunk($task, 1));
dumpType(array_filter($task));
}
There was a problem hiding this comment.
All 12102 tests pass and make phpstan reports no errors.
The reviewer's comment is a question about pre-existing behavior, not a request for a code change. Here's my analysis:
The toBenevolentUnion in ArrayFilterFunctionReturnTypeHelper is pre-existing (not introduced by this PR). It was there before to handle union types like array|object — when array_filter is called on such a type, BenevolentUnionType::unionTypes() skips the ErrorType from the ObjectType member and returns a useful filtered array type. In contrast, array_chunk doesn't use toBenevolentUnion, so it returns *ERROR* for the same input.
This PR's change doesn't affect the array|object case. The new $originalArrayArgType->isArray()->yes() shortcut path (line 84) only triggers when the type is purely an array. For array|object, isArray()->yes() returns false, so execution falls through to the existing getArrays() decomposition path which continues to use $arrayArgType (the benevolent-unioned version).
There are no specific tests for toBenevolentUnion behavior with non-array union types in ArrayFilterFunctionReturnTypeHelper. The inconsistency between array_filter and array_chunk for array|object input is a valid observation but pre-dates this PR and should be investigated separately if it's considered a bug.
No code changes were needed for this review comment since it was a question about pre-existing behavior. Tests and static analysis both pass cleanly.
staabm
left a comment
There was a problem hiding this comment.
do more methods exist in ArrayType which use new self() but should use $this->withTypes() instead?
|
All 12102 tests pass and static analysis is clean. I audited all remaining
There are also two No additional changes needed. All methods that should use |
|
I'm not a big fan of the logic which ends up having to do while calling This seems to be a fix for phpstan/phpstan#10189 I feel like we should have a consistent behavior and wonder:
Maybe instead of the WDYT @staabm ? |
|
I would be fine with reverting the change in |
VincentLanglet
left a comment
There was a problem hiding this comment.
Revert the changes in ArrayFilterFunctionReturnTypeHelper and the related assertions
…ions The template-preserving shortcut in ArrayFilterFunctionReturnTypeHelper is a separate concern from the ArrayType/IntersectionType fixes and deserves its own PR. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Reverted the |
Summary
Follow-up to PRs #5694 and #5696 which fixed
shuffleArray()losing template types. This PR systematically audits all otherType::*arraymethods and applies the same pattern to every method that was losing generic/template types.Changes
src/Type/ArrayType.phpintersectKeyArray():new self(...)→$this->withTypes(...)soTemplateArrayTypepreserves template identitysliceArray(): same fix in the integer-key reindexing branchspliceArray(): same fix for the spliced array constructionmapValueType():new ArrayType(...)→$this->withTypes(...)mapKeyType(): samechangeKeyCaseArray(): samefilterArrayRemovingFalsey(): samesrc/Type/IntersectionType.phppopArray(),shiftArray(),reverseArray(),sliceArray(),spliceArray(),intersectKeyArray(),mapValueType(),mapKeyType(),changeKeyCaseArray(),filterArrayRemovingFalsey(): skip calling the array method onTemplateTypemembers (return the template unchanged), matching the pattern from theshuffleArray()fixgetValuesArray(): preserveTemplateTypeconditionally when the intersection is already a list (sincearray_values()on a list is a no-op)src/Type/Php/ArrayFilterFunctionReturnTypeHelper.phpfilterArrayRemovingFalsey()directly on the original type instead of decomposing viagetArrays(). This preserves the intersection context (and thus template types).Methods probed and found to be already correct
popArray(),shiftArray(),reverseArray()onArrayType: already return$this, soTemplateArrayTypenaturally preserves templatemakeListMaybe(),makeAllArrayKeysOptional()on bothArrayTypeandIntersectionType: already return$thisflipArray(),fillKeysArray(): correctly drop template identity (fundamentally new array structure)getKeysArray(),getValuesArray()onArrayType: correctly drop template (create new derived arrays)setExistingOffsetValueType(),unsetOffset()onArrayType: correctly drop template (explicit mutations that may violate T's contract)Root cause
Two parallel patterns caused the same bug:
ArrayTypelevel: Methods constructingnew self(...)ornew ArrayType(...)instead of$this->withTypes(...)lose the template identity when$thisis aTemplateArrayType, becauseTemplateArrayTypeoverrideswithTypes()to preserve template metadata.IntersectionTypelevel: WhenintersectTypes()calls an array method on each intersection member,TemplateTypemembers (backed byTemplateMixedTypewhich extendsMixedType) return a plainArrayType, losing the template. The fix skips calling the array method onTemplateTypemembers, preserving them as-is — the actual array transformation is applied to the concrete array type members of the intersection.Test
tests/PHPStan/Analyser/nsrt/bug-14633.phpwith regression tests covering:IntersectionTypetemplate preservation forpopArray,shiftArray,reverseArray,sliceArray,getValuesArray,filterArrayRemovingFalsey(both list and non-list variants)ArrayTypetemplate preservation forfilterArrayRemovingFalsey,intersectKeyArray,sliceArrayFixes phpstan/phpstan#14633