From 39454a718c894b7e1b7bed02b9dd178ccc364cdc Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Fri, 13 Feb 2026 04:49:45 +0000 Subject: [PATCH] Fix inconsistent type inference for closures in class constants - InitializerExprTypeResolver now infers closure return types from the body - Builds parameter variable type map and passes it through expression resolution - Uses intersectButNotNever() to combine inferred return type with annotation - New regression test in tests/PHPStan/Analyser/nsrt/bug-14105.php Closes https://github.com/phpstan/phpstan/issues/14105 --- CLAUDE.md | 4 + .../InitializerExprTypeResolver.php | 171 ++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14105.php | 21 +++ 3 files changed, 196 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14105.php diff --git a/CLAUDE.md b/CLAUDE.md index c96c8954ee..187886f6b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -407,6 +407,10 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines When methods are called on union types (`Foo|Bar`), the resolved method reflection is a `UnionTypeMethodReflection` that wraps the individual method reflections. Similarly, `IntersectionTypeMethodReflection` handles intersection types. These two classes must maintain feature parity for things like `getAsserts()`, `getSelfOutType()`, etc. When one class correctly combines member data (e.g. `IntersectionTypeMethodReflection::getAsserts()` iterating over methods and calling `intersectWith()`), the other should do the same rather than returning empty/null. The `Assertions::intersectWith()` method merges assertion tag lists from multiple sources. +### InitializerExprTypeResolver: closure return type inference from body + +`InitializerExprTypeResolver` resolves types for constant expressions (class constant initializers, global constant initializers, default parameter values). For static closures, it constructs a `ClosureType` using parameter types and the return type annotation. Unlike `MutatingScope::getClosureType()` which performs full closure body analysis via `NodeScopeResolver`, `InitializerExprTypeResolver` has no scope — it resolves expressions statically. When inferring closure return types from the body, a `$variableTypes` map must be built from the closure's parameters and passed through to expression resolution, since variables fall through to `MixedType` in the normal `getType()` path. The `resolveExprTypeWithVariables()` helper intercepts `Variable` nodes and `Array_` expressions (which need the variable-aware callback passed to `getArrayType()`) to provide parameter-aware type resolution. The inferred return type is then intersected with the declared return type annotation via `intersectButNotNever()`. + ## Important dependencies - `nikic/php-parser` ^5.7.0 - PHP AST parsing diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 937775031f..e0ddf5c847 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -4,6 +4,7 @@ use Closure; use Nette\Utils\Strings; +use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\ComplexType; use PhpParser\Node\Expr; @@ -96,6 +97,7 @@ use PHPStan\Type\TypeUtils; use PHPStan\Type\TypeWithClassName; use PHPStan\Type\UnionType; +use PHPStan\Type\VoidType; use stdClass; use Throwable; use function array_key_exists; @@ -251,6 +253,24 @@ public function getType(Expr $expr, InitializerExprContext $context): Type $returnType = $this->getFunctionType($expr->returnType, false, false, $context); } + $variableTypes = []; + foreach ($expr->params as $param) { + if (!($param->var instanceof Variable) || !is_string($param->var->name)) { + continue; + } + + $variableTypes[$param->var->name] = $this->getFunctionType($param->type, $this->isParameterValueNullable($param), $param->variadic, $context); + } + + $inferredReturnType = $this->inferClosureReturnType($expr->stmts, $context, $variableTypes); + if ($inferredReturnType !== null) { + if ($expr->returnType !== null) { + $returnType = self::intersectButNotNever($returnType, $inferredReturnType); + } else { + $returnType = $inferredReturnType; + } + } + return new ClosureType( $parameters, $returnType, @@ -503,6 +523,157 @@ public function getType(Expr $expr, InitializerExprContext $context): Type return new MixedType(); } + /** + * @param Node\Stmt[] $stmts + * @param array $variableTypes + */ + private function inferClosureReturnType(array $stmts, InitializerExprContext $context, array $variableTypes): ?Type + { + $returnExprs = []; + $hasNull = false; + $this->collectReturnExpressions($stmts, $returnExprs, $hasNull); + + if ($returnExprs === [] && !$hasNull) { + return null; + } + + $returnTypes = []; + foreach ($returnExprs as $returnExpr) { + $returnTypes[] = $this->resolveExprTypeWithVariables($returnExpr, $context, $variableTypes); + } + + if ($returnTypes === []) { + return new VoidType(); + } + + if ($hasNull) { + $returnTypes[] = new NullType(); + } + + return TypeCombinator::union(...$returnTypes); + } + + /** + * @param array $variableTypes + */ + private function resolveExprTypeWithVariables(Expr $expr, InitializerExprContext $context, array $variableTypes): Type + { + if ($expr instanceof Variable && is_string($expr->name) && isset($variableTypes[$expr->name])) { + return $variableTypes[$expr->name]; + } + + if ($expr instanceof Expr\Array_) { + return $this->getArrayType($expr, fn (Expr $expr): Type => $this->resolveExprTypeWithVariables($expr, $context, $variableTypes)); + } + + return $this->getType($expr, $context); + } + + /** + * @param Node\Stmt[] $stmts + * @param Expr[] $returnExprs + */ + private function collectReturnExpressions(array $stmts, array &$returnExprs, bool &$hasNull): void + { + foreach ($stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Return_) { + if ($stmt->expr !== null) { + $returnExprs[] = $stmt->expr; + } else { + $hasNull = true; + } + continue; + } + + // Skip nested closures, functions, and classes - they have their own scope + if ( + $stmt instanceof Node\Stmt\Function_ + || $stmt instanceof Node\Stmt\Class_ + || $stmt instanceof Node\Stmt\Interface_ + || $stmt instanceof Node\Stmt\Trait_ + || $stmt instanceof Node\Stmt\Enum_ + ) { + continue; + } + + // Check for expression statements containing closures/arrow functions + if ($stmt instanceof Node\Stmt\Expression) { + if ($stmt->expr instanceof Expr\Closure || $stmt->expr instanceof Expr\ArrowFunction) { + continue; + } + } + + // Recurse into compound statements + if ($stmt instanceof Node\Stmt\If_) { + $this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull); + foreach ($stmt->elseifs as $elseif) { + $this->collectReturnExpressions($elseif->stmts, $returnExprs, $hasNull); + } + if ($stmt->else !== null) { + $this->collectReturnExpressions($stmt->else->stmts, $returnExprs, $hasNull); + } + continue; + } + + if ($stmt instanceof Node\Stmt\Foreach_) { + $this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull); + continue; + } + + if ($stmt instanceof Node\Stmt\For_) { + $this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull); + continue; + } + + if ($stmt instanceof Node\Stmt\While_) { + $this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull); + continue; + } + + if ($stmt instanceof Node\Stmt\Do_) { + $this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull); + continue; + } + + if ($stmt instanceof Node\Stmt\Switch_) { + foreach ($stmt->cases as $case) { + $this->collectReturnExpressions($case->stmts, $returnExprs, $hasNull); + } + continue; + } + + if ($stmt instanceof Node\Stmt\TryCatch) { + $this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull); + foreach ($stmt->catches as $catch) { + $this->collectReturnExpressions($catch->stmts, $returnExprs, $hasNull); + } + if ($stmt->finally !== null) { + $this->collectReturnExpressions($stmt->finally->stmts, $returnExprs, $hasNull); + } + continue; + } + + if ($stmt instanceof Node\Stmt\Block) { + $this->collectReturnExpressions($stmt->stmts, $returnExprs, $hasNull); + continue; + } + } + } + + private static function intersectButNotNever(Type $nativeType, Type $inferredType): Type + { + if ($nativeType->isSuperTypeOf($inferredType)->no()) { + return $nativeType; + } + + $result = TypeCombinator::intersect($nativeType, $inferredType); + if (TypeCombinator::containsNull($nativeType)) { + return TypeCombinator::addNull($result); + } + + return $result; + } + /** * @param callable(Expr): Type $getTypeCallback */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-14105.php b/tests/PHPStan/Analyser/nsrt/bug-14105.php new file mode 100644 index 0000000000..69f8b457e1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14105.php @@ -0,0 +1,21 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug14105; + +use function PHPStan\Testing\assertType; + +final readonly class ABC +{ + const func = static function (int $num): array { + return ['num' => $num]; + }; +} + +const func = static function (int $num): array { + return ['num' => $num]; +}; + +assertType('Closure(int): array{num: int}', ABC::func); +assertType('Closure(int): array{num: int}', func);