Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Narrow conditional type branches through NarrowedSubjectType instead …
…of by identity

ConditionalType narrowed the branch types identical (===) to its subject,
also once the subject had resolved to a concrete type - an argument type,
or what a template type was inferred as. Identity is only a proxy for "the
branch mentions the subject" while equal types stay distinct objects, and
they do not: the turbo extension memoizes TypeCombinator results, so every
`int|false` resolved from a PHPDoc is one object, and a caller passing its
own `@param int|false` into `($p is int ? bool : int|false)` got `bool`,
the `int|false` branch narrowed to `false`. Without the extension the same
happens when the argument is that very branch type, as for
`leaf(leaf(false))`.

A branch can only mention the subject through a template type, so the
references are now recognised while they still are one, and narrowed
explicitly:

- `(T is X ? A : B)` replaces T in the branches with NarrowedSubjectType,
  which behaves like `T & X` / `T ~ X` while T is unresolved and recomputes
  the narrowing from whatever T and X resolve to. Resolving the narrowed
  template type alone would only hand back what T resolved to, and `S ~ U`
  with a template type as the target cannot be computed before both
  resolve. A concrete subject is never narrowed.
- `($param is X ? A : B)` with `@param T $param` narrows T the same way,
  before the template types resolve - but only where nothing but the
  parameter binds T. That narrowing used to happen by accident, T having
  been inferred from the argument type object itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QU5puXLGJ7aMwLbyhvFkCu
  • Loading branch information
ondrejmirtes and claude committed Sep 19, 2026
commit 2d056289dbaf35e8837e36d38f39813d5588dcc6
10 changes: 10 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1721,6 +1721,16 @@ jobs:
# the two dumps are the only errors: count the file:line:message lines
../bashunit -a equals '2' "$(echo "$OUTPUT" | grep -Ec '^/.+:[0-9]+:')"

- name: "Verify conditional types leave memoized types equal to the argument alone"
# With the extension active TypeCombinator's results are memoized, so every
# `int|false` resolved from a PHPDoc is the same object. ConditionalType
# narrowed the types in its branches identical to the argument, which made
# `($priority is int ? bool : int|false)` lose the `int` for an `int|false`
# argument - https://github.com/phpstan/phpstan/issues/15268
run: |
cd e2e/bug-15268
php ../../bin/phpstan analyse --no-progress --error-format=raw

- name: "Test"
# The arena is a per-run POSIX shared-memory object: shm_open() plus an
# ftruncate() to 256 MB that tmpfs honours sparsely, committing pages on
Expand Down
4 changes: 4 additions & 0 deletions e2e/bug-15268/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
parameters:
level: 10
paths:
- .
36 changes: 36 additions & 0 deletions e2e/bug-15268/test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare(strict_types = 1);

use function PHPStan\Testing\assertType;

/**
* @param int|false $priority
* @return ($priority is int ? bool : int|false)
*/
function leaf($priority = false) {
return is_int($priority) ? true : 5;
}

/**
* @param int|false $priority
* @return ($priority is int ? bool : int|false)
*/
function wrapper($priority = false) {
// $priority is int|false, so the condition selects neither branch and the result
// should be their union. This assertion passes when PHPStan runs on PHP 8.2, and
// fails with "Expected type bool|int, actual: bool" on PHP 8.3, 8.4 and 8.5.
assertType('bool|int', leaf($priority));

// Because the type above lost its int, PHP 8.3+ additionally reports
// "Function wrapper() never returns int so it can be removed from the return type."
// It does return int: leaf(false) returns 5. No such error on PHP 8.2.
return leaf($priority);
}

// These hold on every PHP version: when the argument type selects a branch, the
// conditional resolves correctly.
assertType('int|false', leaf());
assertType('bool', leaf(10));
assertType('int|false', wrapper());

// Playground result on PHP 8.2 (its own runtime): No errors.
// Run locally on PHP 8.3+: two errors, the failed assertion and return.unusedType.
3 changes: 2 additions & 1 deletion src/Analyser/Traverser/GenericTypeTemplateTraverser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\Generic\TemplateTypeHelper;
use PHPStan\Type\Generic\TemplateTypeMap;
use PHPStan\Type\NarrowedSubjectType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeTraverserCallable;

Expand All @@ -23,7 +24,7 @@ public function __construct(
*/
public function traverse(Type $type, callable $traverse): Type
{
if ($type instanceof TemplateType && !$type->isArgument()) {
if ($type instanceof TemplateType && !$type instanceof NarrowedSubjectType && !$type->isArgument()) {
$newType = $this->resolvedTemplateTypeMap->getType($type->getName());
if ($newType === null || $newType instanceof ErrorType) {
return $type->getDefault() ?? $type->getBound();
Expand Down
108 changes: 105 additions & 3 deletions src/Reflection/ResolvedFunctionVariantWithOriginal.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use PHPStan\Type\Generic\TemplateTypeMap;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
use PHPStan\Type\NarrowedSubjectType;
use PHPStan\Type\NonAcceptingNeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeTraverser;
Expand Down Expand Up @@ -143,15 +144,21 @@ public function getReturnTypeWithUnresolvableTemplateTypes(): Type
{
return $this->returnTypeWithUnresolvableTemplateTypes ??=
$this->resolveConditionalTypesForParameter(
$this->resolveResolvableTemplateTypes($this->parametersAcceptor->getReturnType(), TemplateTypeVariance::createCovariant()),
$this->resolveResolvableTemplateTypes(
$this->narrowTemplateTypesInConditionalTypesForParameter($this->parametersAcceptor->getReturnType()),
TemplateTypeVariance::createCovariant(),
),
);
}

private function getPhpDocReturnTypeWithUnresolvableTemplateTypes(): Type
{
return $this->phpDocReturnTypeWithUnresolvableTemplateTypes ??=
$this->resolveConditionalTypesForParameter(
$this->resolveResolvableTemplateTypes($this->parametersAcceptor->getPhpDocReturnType(), TemplateTypeVariance::createCovariant()),
$this->resolveResolvableTemplateTypes(
$this->narrowTemplateTypesInConditionalTypesForParameter($this->parametersAcceptor->getPhpDocReturnType()),
TemplateTypeVariance::createCovariant(),
),
);
}

Expand Down Expand Up @@ -209,6 +216,7 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
$objectCb = function (Type $type, callable $traverse) use ($references): Type {
if (
$type instanceof TemplateType
&& !$type instanceof NarrowedSubjectType
&& !$type->isArgument()
&& $type->getScope()->getFunctionName() !== null
) {
Expand Down Expand Up @@ -252,7 +260,7 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
return TypeTraverser::map($type, $objectCb);
}

if ($type instanceof TemplateType && !$type->isArgument()) {
if ($type instanceof TemplateType && !$type instanceof NarrowedSubjectType && !$type->isArgument()) {
$newType = $this->resolvedTemplateTypeMap->getType($type->getName());
if ($newType === null || $newType instanceof ErrorType) {
return $traverse($type);
Expand Down Expand Up @@ -288,6 +296,100 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance
});
}

/**
* `($param is X ? A : B)` narrows the parameter, and when the parameter alone binds the
* template type it is declared with, that template type too: `@param T $param` makes the
* references to T in the branches `T & X` and `T ~ X` (see
* ConditionalTypeForParameter::narrowTemplateType()). Has to happen before the template
* types resolve, while the references are still recognizable.
*/
private function narrowTemplateTypesInConditionalTypesForParameter(Type $type): Type
{
if (!$type->hasTemplateOrLateResolvableType()) {
return $type;
}

return TypeTraverser::map($type, function (Type $type, callable $traverse): Type {
if ($type instanceof ConditionalTypeForParameter) {
$templateType = $this->getTemplateTypeBoundOnlyByParameter($type->getParameterName());
if ($templateType !== null) {
$type = $type->narrowTemplateType($templateType);
}
}

return $traverse($type);
});
}

/**
* The template type of the function the parameter is declared as, provided no other
* parameter references it - another parameter could bind it to values a condition on
* this parameter says nothing about. A reference through the bound of another template
* type counts too: `@param class-string<TFormType> $type` with
* `@template TFormType of FormTypeInterface<TData>` binds TData.
*/
private function getTemplateTypeBoundOnlyByParameter(string $parameterName): ?TemplateType
{
$templateType = null;
foreach ($this->parametersAcceptor->getParameters() as $parameter) {
if ('$' . $parameter->getName() !== $parameterName) {
continue;
}
if ($parameter->isVariadic()) {
return null;
}

$type = $parameter->getType();
if (
!$type instanceof TemplateType
|| $type instanceof NarrowedSubjectType
|| $type->getScope()->getFunctionName() === null
) {
return null;
}

$templateType = $type;
break;
}

if ($templateType === null) {
return null;
}

foreach ($this->parametersAcceptor->getParameters() as $parameter) {
if ('$' . $parameter->getName() === $parameterName) {
continue;
}

if (self::referencesTemplateType($parameter->getType(), $templateType)) {
return null;
}
}

return $templateType;
}

private static function referencesTemplateType(Type $type, TemplateType $templateType): bool
{
$references = false;
TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($templateType, &$references): Type {
if (
$type instanceof TemplateType
&& $type->getName() === $templateType->getName()
&& $type->getScope()->equals($templateType->getScope())
) {
$references = true;

return $type;
}

// a template type traverses into its bound
return $traverse($type);
});

return $references;
}

private function resolveConditionalTypesForParameter(Type $type): Type
{
return TypeTraverser::map($type, function (Type $type, callable $traverse): Type {
Expand Down
49 changes: 24 additions & 25 deletions src/Type/ConditionalType.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\Traits\LateResolvableTypeTrait;
use PHPStan\Type\Traits\NonGeneralizableTypeTrait;
Expand All @@ -21,10 +22,6 @@ final class ConditionalType implements CompoundType, LateResolvableType

private ?Type $normalizedElse = null;

private ?Type $subjectWithTargetIntersectedType = null;

private ?Type $subjectWithTargetRemovedType = null;

public function __construct(
private Type $subject,
private Type $target,
Expand Down Expand Up @@ -195,32 +192,34 @@ public function toPhpDocNode(): TypeNode

private function getNormalizedIf(): Type
{
return $this->normalizedIf ??= TypeTraverser::map(
$this->if,
fn (Type $type, callable $traverse) => $type === $this->subject
? (!$this->negated ? $this->getSubjectWithTargetIntersectedType() : $this->getSubjectWithTargetRemovedType())
: $traverse($type),
);
return $this->normalizedIf ??= $this->narrowSubjectIn($this->if, !$this->negated);
}

private function getNormalizedElse(): Type
{
return $this->normalizedElse ??= TypeTraverser::map(
$this->else,
fn (Type $type, callable $traverse) => $type === $this->subject
? (!$this->negated ? $this->getSubjectWithTargetRemovedType() : $this->getSubjectWithTargetIntersectedType())
: $traverse($type),
);
}

private function getSubjectWithTargetIntersectedType(): Type
{
return $this->subjectWithTargetIntersectedType ??= TypeCombinator::intersect($this->subject, $this->target);
}
return $this->normalizedElse ??= $this->narrowSubjectIn($this->else, $this->negated);
}

/**
* Replaces the references to the subject in a branch with what the branch knows about
* it: `subject & target` where the condition holds, `subject ~ target` where it does not
* (see NarrowedSubjectType).
*
* A branch can only reference the subject while it is a template type. Once the subject
* resolves to a concrete type, an equal type inside the branch is not a mention of it -
* the `mixed` value type of an `array` branch has nothing to do with a `mixed` subject -
* so nothing is narrowed here; the references narrowed earlier follow the resolution on
* their own. Matching by identity instead would still hit such unrelated types whenever
* they happen to share an instance - the turbo extension memoizes TypeCombinator
* results, so each `int|false` resolved from a PHPDoc is the same object.
*/
private function narrowSubjectIn(Type $branch, bool $conditionHolds): Type
{
if (!$this->subject instanceof TemplateType) {
return $branch;
}

private function getSubjectWithTargetRemovedType(): Type
{
return $this->subjectWithTargetRemovedType ??= TypeCombinator::remove($this->subject, $this->target);
return NarrowedSubjectType::narrowReferences($branch, $this->subject, $this->target, $conditionHolds);
}

}
Loading
Loading