Skip to content

Check the subtracted type when a subtracted mixed accepts a value - #6167

Open
zonuexe wants to merge 4 commits into
phpstan:2.2.xfrom
zonuexe:non-empty-mixed-accepts
Open

Check the subtracted type when a subtracted mixed accepts a value#6167
zonuexe wants to merge 4 commits into
phpstan:2.2.xfrom
zonuexe:non-empty-mixed-accepts

Conversation

@zonuexe

@zonuexe zonuexe commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#15033

MixedType::accepts (and StrictMixedType, reached once RuleLevelHelper rewrites an explicit MixedType for checkExplicitMixed) accepted every value unconditionally, so a subtracted mixed like non-empty-mixed rejected nothing at argument or return boundaries, even though the same subtraction already powered narrowing and reachability (identical.alwaysFalse, if.alwaysTrue, etc.).

Acceptance now turns to No only on a definite hit (subtractedType->isSuperTypeOf($given)->yes()), so partial overlaps (e.g. a general string into non-empty-mixed, which may or may not be '') stay accepted — this preserves mixed's usual looseness and is why the fix isn't isSuperTypeOf(...)->toAcceptsResult(). NeverType is exempted, mirroring MixedType::isSuperTypeOf.

RuleLevelHelper::transformCommonType now carries the subtraction through when it converts an explicit MixedType into StrictMixedType, instead of discarding it — otherwise level max stayed silent even with MixedType fixed. VerbosityLevel::getRecommendedLevelByType escalates to precise() when a subtracted (Strict)MixedType is involved, so messages render the subtraction instead of a bare, uninformative mixed.

@staabm
staabm force-pushed the non-empty-mixed-accepts branch from e8428d1 to a5e98b4 Compare August 3, 2026 10:46
Comment thread src/Rules/RuleLevelHelper.php
@zonuexe
zonuexe marked this pull request as draft August 3, 2026 13:39
@zonuexe
zonuexe marked this pull request as ready for review August 3, 2026 14:18
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

Comment thread src/Type/VerbosityLevel.php Outdated
Comment on lines +131 to +153
// A subtracted mixed only makes sense in an error message when the subtraction
// is spelled out. Template bounds are skipped - the subtraction there belongs
// to the bound, not to the type being described.
$hasSubtractedMixed = false;
TypeTraverser::map($acceptingType, static function (Type $type, callable $traverse) use (&$hasSubtractedMixed): Type {
if ($hasSubtractedMixed || $type instanceof TemplateType) {
return $type;
}

if (
($type instanceof MixedType || $type instanceof StrictMixedType)
&& $type->getSubtractedType() !== null
) {
$hasSubtractedMixed = true;
return $type;
}

return $traverse($type);
});

if ($hasSubtractedMixed) {
return self::precise();
}

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.

couldn't this additional type-traversal be prevented by merging this logic into the pre-existing $moreVerboseCallback below?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Merged in 14d40d3. A depth counter or boolean flag for the template-bound exclusion fails make phpstan: the self-analysis reports identical.alwaysTrue because it doesn't model the callback re-entering through $traverse. So the existing branch block moved into $flagsCallback; the outer callback maps template subtrees with it, escalates on subtracted mixed, and delegates the rest. Single traversal, no extra pass. Side effect: the accepted-type traversal in the invariant-template path now detects subtracted mixed too, so those messages also render the subtraction.

@zonuexe
zonuexe force-pushed the non-empty-mixed-accepts branch from 6877410 to 14d40d3 Compare August 5, 2026 07:18
Comment on lines +3028 to +3031
public function testNonEmptyMixedParameter(bool $checkExplicitMixed): void
{
$this->checkExplicitMixed = $checkExplicitMixed;
$this->checkImplicitMixed = $checkExplicitMixed;

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.

Suggested change
public function testNonEmptyMixedParameter(bool $checkExplicitMixed): void
{
$this->checkExplicitMixed = $checkExplicitMixed;
$this->checkImplicitMixed = $checkExplicitMixed;
public function testNonEmptyMixedParameter(bool $checkImplicitAndExplicitMixed): void
{
$this->checkExplicitMixed = $checkImplicitAndExplicitMixed;
$this->checkImplicitMixed = $checkImplicitAndExplicitMixed;

@zonuexe
zonuexe force-pushed the non-empty-mixed-accepts branch 2 times, most recently from 0cd84dd to f620552 Compare August 7, 2026 01:59
@staabm
staabm force-pushed the non-empty-mixed-accepts branch from f620552 to 910ae5c Compare August 10, 2026 07:52
@staabm

staabm commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

@SanderMuller please review

@SanderMuller

SanderMuller commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Reviewed, and I ran it rather than only reading it. The change is sound and the inconsistency it closes is real: the same subtraction that already drives identical.alwaysFalse inside the body was ignored at the boundary, so PHPStan claimed $x === null was impossible and accepted null from the caller in the same function.

Behaviour on my fixture, merge-base 81e06a583 against the head, with /** @param non-empty-mixed $x */ function takes($x):

  • newly reported: takes(null), takes(''), takes(0), takes(false), takes([]), and return '' from a @return non-empty-mixed. The message reads Parameter #1 $x of function P\takes expects mixed~(0|0.0|''|'0'|array{}|false|null), null given.
  • still accepted, correctly: takes($someString), takes('a'), takes(1), takes($mixed), and takes($nullableString). After narrowing that last one to null it is reported.

So six new true positives and no false positive on the partial-overlap cases, which is what the ->yes() guard is there to protect. The VerbosityLevel escalation earns its place too: without it the message would read expects mixed, null given, which would be baffling.

One thing the description does not mention: this fires from level 5, not only at the explicit-mixed levels. I get the same six errors at 5 and at 9, because MixedType::accepts() sits on the ordinary argument path and only the StrictMixedType half needs checkExplicitMixed.

Gates on the head: full suite green (21318 tests, 96893 assertions), self-analysis clean.

Performance, since accepts() is a hot path: no regression. Three interleaved rounds on a doctrine/symfony codebase give medians of 132.6s on the base and 132.0s with the PR, against a within-build spread of 1.3s. The ordering helps, since $this->subtractedType !== null is tested first, so plain mixed costs one null check and the two describe() calls only run on rejection.

Both red jobs are one infrastructure failure rather than this PR. Integration - staabm/phpstan-dba and Integration - phpstan-dba tests both died in composer install with curl error 28 fetching dogma/dogma from codeberg.org (broken pipe in one job, connection reset in the other), exit code 100, so the tests never ran.

One question for @ondrejmirtes: should this sit behind bleedingEdge? Nothing in conf/ changed, so a minor upgrade starts reporting a new error class from level 5 upwards. The errors are true positives and you have to write non-empty-mixed deliberately to hit them, so I can see it going either way, but it seemed worth asking rather than assuming.

Two smaller things that both look right to me: TemplateMixedType::toStrictMixedType() carries the subtraction into the bound, which matches the note in VerbosityLevel about the subtraction belonging to the bound; and TemplateStrictMixedType now calls parent::__construct(). Correcting myself there: I first wrote that it has to, then checked, and it does not. Removing the call leaves getSubtractedType() returning null and accepts() working, because the promoted parameter's default lands on the property anyway. So it is hygiene rather than a requirement, and still worth keeping.

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.

non-empty-mixed is used for narrowing but not enforced at call/return boundaries

4 participants