Skip to content

Do not use callable parameter types as native types for closure and arrow function parameters#5632

Open
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-g6kjenw
Open

Do not use callable parameter types as native types for closure and arrow function parameters#5632
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-g6kjenw

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When treatPhpDocTypesAsCertain is false, PHPStan incorrectly reports function.alreadyNarrowedType for type checks inside closures/arrow functions passed to array_map, array_filter, and similar functions. The issue is that PHPDoc-derived callable parameter types were being treated as native types.

Changes

  • Modified MutatingScope::enterAnonymousFunctionWithoutReflection() to track the declared PHP type (before intersection with callable parameters) separately and use it for native expression types
  • Modified MutatingScope::enterArrowFunctionWithoutReflection() with the same fix — pass the declared type as the native type argument to assignVariable()
  • Both fixes ensure that callable parameter types derived from PHPDoc/generics in the calling context are treated as PHPDoc-level information only, not as native types

Root cause

In enterAnonymousFunctionWithoutReflection, lines 2037-2039 (before fix):

$holder = ExpressionTypeHolder::createYes($parameter->var, $parameterType);
$expressionTypes[$paramExprString] = $holder;
$nativeTypes[$paramExprString] = $holder;  // BUG: same holder for both

The $parameterType included the callable parameter type from the calling context (e.g., string from array_map(fn($item) => ..., $stringArray)). This type was derived from PHPDoc annotations (@param string[] $values), but was incorrectly used as the native type. When treatPhpDocTypesAsCertain is false, the rule uses $scope->getNativeType() which returned string instead of mixed, making it report is_string() as always true.

The same pattern existed in enterArrowFunctionWithoutReflection where assignVariable($name, $parameterType, $parameterType, ...) used the same enriched type for both the expression type and native type.

This mirrors how foreach correctly handles the distinction: it calls getIterableValueType() on both the PHPDoc type AND the native type separately, using assignVariable($name, $valueType, $nativeValueType, ...).

Analogous cases probed

  • Closure in array_map — was broken, now fixed ✓
  • Arrow function in array_map — was broken, now fixed ✓
  • Closure in array_filter — was broken, now fixed ✓
  • Arrow function in array_filter — was broken, now fixed ✓
  • usort, uasort, uksort and all other callback-accepting functions — go through the same code path, now fixed ✓
  • Method and static method call rules (ImpossibleCheckTypeMethodCallRule, ImpossibleCheckTypeStaticMethodCallRule) — use the same ImpossibleCheckTypeHelper and same $scope->getNativeType(), so they benefit from the scope fix ✓
  • foreach loop — was already correct (uses separate native/PHPDoc iterable types) ✓
  • Closure with explicit PHP type hint — still works correctly (native type = declared type) ✓

Test

  • Added rule test testBug11014 in ImpossibleCheckTypeFunctionCallRuleTest with test data covering all four combinations (closure/arrow × array_map/array_filter)
  • Added NSRT test tests/PHPStan/Analyser/nsrt/bug-11014.php verifying native types via assertNativeType('mixed', $item) for untyped callback parameters and assertNativeType('string', $item) for typed ones

Fixes phpstan/phpstan#11014

…rrow function parameters

- In `enterAnonymousFunctionWithoutReflection` and `enterArrowFunctionWithoutReflection`,
  the callable parameter types (derived from calling context, often via PHPDoc/generics)
  were used as both expression types AND native types for closure parameters
- Now only the declared PHP type hint is used for native types, while the intersected
  callable parameter type is used for expression types (PHPDoc-aware)
- This matches the behavior of `foreach` which correctly distinguishes native vs PHPDoc
  iterable value types
- Fixes false positives from `function.alreadyNarrowedType` when `treatPhpDocTypesAsCertain`
  is false and callbacks are passed to array_map, array_filter, usort, etc.
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add non regression test for phpstan/phpstan#5271

Add a nsrt test for the constant array

$undefined_curl_functions = array_filter(
	[
		'curl_multi_add_handle',
		'curl_multi_exec',
		'curl_multi_init',
	],
	static function( $function_name ) {
        assertType(..., $function_name);
        assertNativeType(..., $function_name);

		return true;
	}
);

@VincentLanglet VincentLanglet self-assigned this May 10, 2026
Non-regression test for phpstan/phpstan#5271. Verifies that
when a constant literal array is passed to array_filter, the
callback parameter gets the correct PHPDoc type (union of
literal strings) and native type (mixed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

make tests also completed successfully. Both verification steps passed, and the commit is already pushed. All done.

],
static function ($function_name) {
assertType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $function_name);
assertNativeType('mixed', $function_name);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the native type is not

'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'

in this case ?

This should be consistent with

$a = [
				'curl_multi_add_handle',
				'curl_multi_exec',
				'curl_multi_init',
		];

		foreach ($a as $b) {
			\PHPStan\Testing\assertNativeType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $b);
		}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed successfully. Here's a summary of the changes:

Problem: The reviewer pointed out that for constant arrays passed to array_filter, the native type of the callback parameter should be the literal string union (consistent with foreach), not mixed. The previous fix was too conservative — it always used only the declared PHP type hint as the native type, ignoring callable parameter types entirely.

Solution: Track native callable parameters separately from PHPDoc-enriched callable parameters:

  1. ArrayFilterArgVisitor — Extended to attach the array argument expression to the callback closure (for the default mode case without a 3rd $mode argument), enabling native type computation.

  2. NodeScopeResolver::createNativeCallableParameters() — New method that computes native callable parameters from:

    • ArrayMapArgVisitor attribute → $scope->getNativeType(arrayArg)->getIterableValueType()
    • ArrayFilterArgVisitor::CALLBACK_ATTRIBUTE_NAME attribute → $scope->getNativeType(arrayArg)->getIterableValueType()
  3. MutatingScope — Added $nativeCallableParameters parameter to enterAnonymousFunction, enterAnonymousFunctionWithoutReflection, enterArrowFunction, and enterArrowFunctionWithoutReflection. When present, native callable parameters are used for native type computation (intersected with the declared PHP type). When null, the declared PHP type is used alone.

  4. ClosureTypeResolver and NodeScopeResolver — Updated all callers to compute and pass native callable parameters.

  5. Tests — Updated NSRT test to assert correct native types for constant arrays ('curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'), and added array_map and foreach constant array tests for consistency verification.

…ow functions

When callable parameter types come from native type information (e.g.,
constant arrays), they should be reflected in the native type of the
closure/arrow function parameter. Previously, the fix always used only
the declared PHP type hint as the native type, which was too conservative
for constant arrays.

This adds a $nativeCallableParameters parameter to enterAnonymousFunction,
enterAnonymousFunctionWithoutReflection, enterArrowFunction, and
enterArrowFunctionWithoutReflection. The native callable parameters are
computed from $scope->getNativeType() for array_map arguments and from the
array argument's native type for array_filter callbacks.

This ensures consistency with foreach: for constant arrays, the native
type is the literal string union, while for PHPDoc-typed arrays, the
native type remains mixed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not default to null for the param nativeCallableParameters this will simplify the code

All callers already pass the argument explicitly, so the default
is unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Removed the = null default from all four $nativeCallableParameters parameters in MutatingScope.php. All callers already pass the argument explicitly, so no other changes were needed. Tests and make phpstan both pass.

Comment thread src/Analyser/MutatingScope.php Outdated
$expressionTypes[$paramExprString] = $holder;
$nativeTypes[$paramExprString] = $holder;
$nativeParameterType = $declaredParameterType;
if ($nativeCallableParameters !== null) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a duplicate of the whole logic

if ($callableParameters !== null) {

There is something to refactor

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Extracted the duplicated callable parameter resolution logic into a private resolveCallableParameterType() helper method, eliminating the code duplication in both enterAnonymousFunctionWithoutReflection and enterArrowFunctionWithoutReflection. The four duplicated if/elseif/else blocks are now two-line calls each. Tests and make phpstan both pass.

Comment thread src/Analyser/MutatingScope.php Outdated
}

$nativeParameterType = $declaredParameterType;
if ($nativeCallableParameters !== null) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same it's a duplicate of the whole f ($callableParameters !== null) {

there is something to refactor

…ter logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge both bug11014 into a single file use for both nsrt and rule test.

And use // lint >= 8.0 on it

Comment thread src/Analyser/MutatingScope.php
Comment thread src/Analyser/MutatingScope.php
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Merged the two bug-11014 test files into a single tests/PHPStan/Analyser/nsrt/bug-11014.php with // lint >= 8.0, deleted the separate rule test data file, and updated the rule test to reference the nsrt file. All tests and make phpstan pass.

These methods are part of the public API for extension developers,
so the new parameter must have a default value to avoid breaking
existing callers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet requested review from VincentLanglet and staabm and removed request for staabm May 10, 2026 19:03
}
}
}
$nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the logic for nativeCallableParameters is not the same than the one for $callableParameters ?

We ends up with a logic which seems hardcoded to only support array_map and array_filter

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Here's a summary of the changes made to address the review:

Problem: The reviewer pointed out that createNativeCallableParameters in ClosureTypeResolver used completely different logic from the $callableParameters computation — it was hardcoded to only support array_map and array_filter.

Changes:

  1. NodeScopeResolver: Extracted shared callable parameter resolution logic into doCreateCallableParameters() that accepts a type-getter closure. Both createCallableParameters() (using getType) and createNativeCallableParameters() (using getNativeType) delegate to it. The new createNativeCallableParameters accepts the same $args and $nativePassedToType parameters as createCallableParameters, with attribute-based fallback for array_map/array_filter where the general mechanism can't derive native types from template resolution.

  2. ClosureTypeResolver: Computes $nativeCallableParameters following the same 4-case structure as $callableParameters (array_map, array_filter, immediately-invoked, inFunctionCallsStack), using getNativeType instead of getType in each case.

  3. processClosureNode/processArrowFunctionNode: Added $nativePassedToType parameter, passed from processArgs via $parameterNativeType.

Make createNativeCallableParameters follow the same structure as
createCallableParameters by extracting shared logic into a
doCreateCallableParameters helper that accepts a type-getter closure.

In ClosureTypeResolver, compute $nativeCallableParameters following
the same case structure as $callableParameters (array_map, array_filter,
immediately-invoked, inFunctionCallsStack) instead of delegating to a
separate method with hardcoded checks.

Pass $nativePassedToType through processClosureNode and
processArrowFunctionNode so that the general callable parameter
resolution mechanism works for native types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
$callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
$nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
}
} elseif ($arrayFilterArrayArg !== null) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the special if for arrayFilterArrayArg is needed ? Can't it rely on the immediatelyInvokedArgs condition ? (and maybe arrayMapArgs too ?)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're out of extra usage · resets 10:20pm (UTC)

[, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1];
if ($inParameter !== null) {
$callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType());
$nativePassedToType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no needed for nativePassedToType var, use it directly in the createNativeCallableParameters call

* @param Node\Arg[]|null $args
* @return ParameterReflection[]|null
*/
public function createNativeCallableParameters(Scope $scope, Expr $closureExpr, ?array $args, ?Type $nativePassedToType): ?array
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't createNativeCallableParameters just rely on doCreateCallableParameters call like createCallableParameters ?

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.

2 participants