From 09412d91acfbd7f61959a1df8dc32bbedd68472c Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 15 Feb 2026 14:24:12 +0200 Subject: [PATCH 1/2] fixed unpacked types being not handled in unions --- .../KeyedArrayUnpackingTypeVisitor.php | 20 ++++++++++ src/Infer/Services/ReferenceTypeResolver.php | 13 ++----- .../Services/UnionNormalizingTypeVisitor.php | 20 ++++++++++ src/Support/Type/TypeTraverser.php | 26 +++++++++++-- .../Services/ReferenceTypeResolverTest.php | 37 +++++++++++++++++++ 5 files changed, 103 insertions(+), 13 deletions(-) create mode 100644 src/Infer/Services/KeyedArrayUnpackingTypeVisitor.php create mode 100644 src/Infer/Services/UnionNormalizingTypeVisitor.php diff --git a/src/Infer/Services/KeyedArrayUnpackingTypeVisitor.php b/src/Infer/Services/KeyedArrayUnpackingTypeVisitor.php new file mode 100644 index 000000000..a3eb2d61b --- /dev/null +++ b/src/Infer/Services/KeyedArrayUnpackingTypeVisitor.php @@ -0,0 +1,20 @@ +replace( - $resolvedType, - fn (Type $t) => $t instanceof Union ? TypeHelper::mergeTypes(...$t->types) : null, - ); - - return $this->resolveLateTypes($finalizedResolvedType->setOriginal($originalType), $originalType)->widen(); + return $this->finalizeType($resolvedType->setOriginal($originalType), $originalType)->widen(); } private function doResolve(Type $t, Type $type, Scope $scope): Type @@ -121,11 +114,13 @@ private function resolveLateTypeEarly(LateResolvingType $type): Type return $type->resolve(); } - private function resolveLateTypes(Type $type, Type $originalType): Type + private function finalizeType(Type $type, Type $originalType): Type { $attributes = $type->attributes(); $traverser = new TypeTraverser([ + new UnionNormalizingTypeVisitor, + new KeyedArrayUnpackingTypeVisitor, new LateTypeResolvingTypeVisitor, ]); diff --git a/src/Infer/Services/UnionNormalizingTypeVisitor.php b/src/Infer/Services/UnionNormalizingTypeVisitor.php new file mode 100644 index 000000000..d07e0a0c2 --- /dev/null +++ b/src/Infer/Services/UnionNormalizingTypeVisitor.php @@ -0,0 +1,20 @@ +types); + } +} diff --git a/src/Support/Type/TypeTraverser.php b/src/Support/Type/TypeTraverser.php index dfe04008d..5b45d7bbf 100644 --- a/src/Support/Type/TypeTraverser.php +++ b/src/Support/Type/TypeTraverser.php @@ -49,20 +49,38 @@ public function traverse(Type $type): Type private function enterType(Type $type): Type|int|null { $result = null; + $resultType = $type; foreach ($this->visitors as $visitor) { - $result = $visitor->enter($result instanceof Type ? $result : $type); + $enterResult = $visitor->enter($resultType); + + if ($enterResult === TypeVisitor::DONT_TRAVERSE_CURRENT_AND_CHILDREN) { + return $enterResult; + } + + if ($enterResult instanceof Type) { + $resultType = $enterResult; + } + + $result = $enterResult; } - return $result; + return $resultType === $type ? $result : $resultType; } private function leaveType(Type $type): Type|int|null { $result = null; + $resultType = $type; foreach ($this->visitors as $visitor) { - $result = $visitor->leave($result instanceof Type ? $result : $type); + $leaveResult = $visitor->leave($resultType); + + if ($leaveResult instanceof Type) { + $resultType = $leaveResult; + } + + $result = $leaveResult; } - return $result; + return $resultType === $type ? $result : $resultType; } } diff --git a/tests/Infer/Services/ReferenceTypeResolverTest.php b/tests/Infer/Services/ReferenceTypeResolverTest.php index f50afc7d0..be52ea206 100644 --- a/tests/Infer/Services/ReferenceTypeResolverTest.php +++ b/tests/Infer/Services/ReferenceTypeResolverTest.php @@ -231,3 +231,40 @@ public function toString(): string ]), )->toString())->toBe('string(wow)'); }); + +it('resolves spread in union type', function () { + $def = analyzeFile(<<<'EOD' + [ + ...$this->typeA(), + 'type' => 'a', + ], + 1 => [ + ...$this->typeB(), + 'type' => 'b', + ], + }; + } + + private function typeA(): array + { + return ['id' => 1, 'name' => 'Type A']; + } + + private function typeB(): array + { + return ['id' => 2, 'name' => 'Type B']; + } +} +EOD)->getClassDefinition('JsonResourceExtensionTest_SpreadInMatch'); + + expect($def->getMethod('toArray')->getReturnType()->toString()) + ->toBe('array{id: int(1), name: string(Type A), type: string(a)}|array{id: int(2), name: string(Type B), type: string(b)}'); +}); + + From fd7f5aa31fb03c3003ae9dd561b965daa63ebf5c Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Sun, 15 Feb 2026 12:25:56 +0000 Subject: [PATCH 2/2] Fix styling --- tests/Infer/Services/ReferenceTypeResolverTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Infer/Services/ReferenceTypeResolverTest.php b/tests/Infer/Services/ReferenceTypeResolverTest.php index be52ea206..4f5d73b7e 100644 --- a/tests/Infer/Services/ReferenceTypeResolverTest.php +++ b/tests/Infer/Services/ReferenceTypeResolverTest.php @@ -266,5 +266,3 @@ private function typeB(): array expect($def->getMethod('toArray')->getReturnType()->toString()) ->toBe('array{id: int(1), name: string(Type A), type: string(a)}|array{id: int(2), name: string(Type B), type: string(b)}'); }); - -