Skip to content

Closure with a nullable native return type and a never-returning body is inferred as Closure(): null instead of Closure(): never #15285

Closed
phpstan/phpstan-src
#6504

Description

@ondrejmirtes

Bug report

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
 */
public function addToCart(bool $isAvailable, Closure $errorCallback): void
{
	// ...
	$errorCallback(
		'This offer is no longer available.',
		fn (): null => $this->redirect('/product'), // redirect(): never
	);
}
26: Parameter #2 of closure expects (Closure(): never)|null, Closure(): null given.

The result also depends on whether the native type is nullable:

\PHPStan\dumpType(fn () => $this->redirect('/product'));        // Closure(): never
\PHPStan\dumpType(fn (): null => $this->redirect('/product'));  // Closure(): null
\PHPStan\dumpType(fn (): int => throw new \LogicException());   // Closure(): never
\PHPStan\dumpType(fn (): ?int => throw new \LogicException());  // Closure(): null

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.

Code snippet that reproduces the problem

https://phpstan.org/r/d99b1b0b-4df9-4c60-a49b-78117770a3dc

Expected output

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

  1. added this to the Easy fixes milestone on Sep 21, 2026
  2. VincentLanglet commented on Sep 21, 2026

    @VincentLanglet
    Contributor

    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 ?

  3. ondrejmirtes commented on Sep 21, 2026

    @ondrejmirtes
    MemberAuthor

    Yeah I will it was on 2.3.x by mistake

  4. github-actions commented on Oct 23, 2026

    @github-actions

    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.

  5. locked as resolved and limited conversation to collaborators on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions