You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Closure with a nullable native return type and a never-returning body is inferred as Closure(): null instead of Closure(): never #15285
A closure or arrow function whose body never returns is inferred as Closure(): null instead of Closure(): never when its native return type contains null. Passing it where Closure(): never is expected is then reported:
/** * @param Closure(string, (Closure(): never)|null=): never $errorCallback */publicfunctionaddToCart(bool$isAvailable, Closure$errorCallback): void
{
// ...$errorCallback(
'This offer is no longer available.',
fn (): null => $this->redirect('/product'), // redirect(): never
);
}
This regressed in phpstan/phpstan-src#6493 (TypeCombinator::addNull() now returns null for never), which is not released yet. 2.2.14 infers Closure(): never in all four cases.
The addNull() change itself looks right. The problem is that MutatingScope::intersectButNotNever() relied on the old behaviour:
$result = TypeCombinator::intersect($nativeType, $inferredType);
if (TypeCombinator::containsNull($nativeType)) {
return TypeCombinator::addNull($result);
}
With native null / ?int and inferred never, $result is never. addNull(never) used to return never and now returns null. ClosureTypeResolver uses this helper for closure and arrow function return types, and MutatingScope::enterAnonymousFunctionWithoutReflection() uses it for parameter types. So a ?int closure parameter in a callable(never): void context (e.g. array_map() over list<never>) is now null inside the body instead of never.
fn (): null => $this->redirect(...) and fn (): ?int => throw ... should be Closure(): never, as they are without a native return type or with a non-nullable one, and line 26 should not be reported.
Suggested fix: in MutatingScope::intersectButNotNever(), return the intersection unchanged when it is never, before adding null. That restores the previous results for both return and parameter types and keeps the improved addNull().
Did PHPStan help you today? Did it make you happy in any way?
Caught by running the 2.2.x dev build on a large private codebase before the release.
Activity
VincentLanglet commentedon Sep 21, 2026
You merged phpstan/phpstan-src#6504 on 2.3.x @ondrejmirtes but you might want the fix on 2.2.x too since you talked about 2.2.x-dev ?
ondrejmirtes commentedon Sep 21, 2026
Yeah I will it was on 2.3.x by mistake
github-actions commentedon Oct 23, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.