Skip to content

Do not report argument.templateType for a template absorbed by another union member - #6366

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-949trp0
Sep 3, 2026
Merged

Do not report argument.templateType for a template absorbed by another union member#6366
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-949trp0

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Since dc41d1e ("Do not bind a template type to an argument absorbed by another union member"), passing null to a @param T|null parameter reports argument.templateType. The signature explicitly declares that null is acceptable and carries no information about T, so the call is legal by construction — Assert::notNull($nullValue) and Arrays::removeNull([null, null]) are correct code.

The inference change itself is what makes new Collection() take its assigned property's declared type (#8441), so it stays. What this PR fixes is that "unresolved" and "worth reporting" were the same thing: a template that stayed unresolved only because every received type was absorbed by another member of a union parameter type is now marked as unresolved on purpose and no longer reported. Inferred types are unchanged.

Changes

  • src/Type/Generic/AbsorbedTemplateArgumentType.php (new) — an ErrorType subclass that marks a template left unresolved on purpose. Because it is an ErrorType, every place that materializes a type (TemplateTypeHelper::resolveTemplateTypes(), ResolvedFunctionVariantWithOriginal, NewHandler, GenericTypeTemplateTraverser, GenericObjectType) keeps treating the template as unresolved and falls back to its default or bound.
  • src/Type/UnionType.phpinferTemplateTypes() binds the union's naked template members to the marker when $remainingReceivedTypes ends up empty. The member-is-a-naked-template test used by the fallback loop is extracted into getNakedTemplateType() and reused, so class-string<T> members are covered too.
  • src/Type/Generic/TemplateTypeMap.phpunion() and benevolentUnion() now let an actual inferred type win over the marker outright instead of combining them, so a template that one occurrence of the parameter type absorbed can still be resolved by another occurrence.
  • src/Rules/FunctionCallParametersCheck.php — skips the marker, next to the existing explicit-NeverType carve-out for unfilled variadics.
  • tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php — drops the two Service::collection() expectations added by dc41d1e; they are exactly the @param T|null / @return Collection<T> shape the issue reports.

Analogous cases

All seven report sites of the message (CallMethodsRule, CallStaticMethodsRule, CallToFunctionParametersRule, CallCallablesRule, CallUserFuncRule, InstantiationRule, AttributesCheck) route through FunctionCallParametersCheck, so the single carve-out covers all of them.

Probed and found broken, now fixed (each has a failing-before test):

  • @param T|false receiving false
  • @param T|int|float receiving 1 / 1.2
  • @param list<T|null> receiving list<null>
  • @param iterable<T|null> receiving an all-null iterable
  • @param array{T|null} receiving array{null}
  • @param callable(): (T|null) receiving a closure returning null
  • @param T|null ...$v receiving only nulls
  • @param class-string<T>|null receiving null

Probed and already correct, left alone:

  • new Coll(null) where Coll is @template T with @param T|null — a constructor has no return type, so the template never reaches the reported set.
  • @param Coll<T|null> receiving Coll<mixed> — nested generic object, not reported before or after.
  • @param array<T>|null receiving array{} — the union member holding T is not a naked template, so nothing was absorbed and the report is genuine; still reported.
  • @param T|null $a, @param T $b called with (null, $o)T still resolves to stdClass; this is what the TemplateTypeMap change protects.

Root cause

UnionType::inferTemplateTypes() absorbs a received type into the union member that definitely accepts it. When every received type is absorbed, the union's naked template members are left with nothing — correctly, since T|null receiving null says nothing about T. But the resolved template type map cannot express "deliberately unresolved": GenericParametersAcceptorResolver::resolve() fills every unresolved template with a plain ErrorType, and FunctionCallParametersCheck reports on exactly that ErrorType. So an argument the signature explicitly allows became indistinguishable from an argument that gives PHPStan no way to know what T is.

The fix gives absorption its own marker so the two are distinguishable. It follows the precedent already in FunctionCallParametersCheck, which skips the explicit NeverType that GenericParametersAcceptorResolver binds to an unfilled variadic — also an "unresolved on purpose" case.

Test

  • tests/PHPStan/Rules/Methods/data/bug-15168.php + CallStaticMethodsRuleTest::testBug15168() — the issue's reproducer verbatim (Assert::notNull() on a narrowed null and on a literal null, Arrays::removeNull([1, null, 2]) and Arrays::removeNull([null, null])), expecting no errors. Fails with three argument.templateType errors without the fix.
  • tests/PHPStan/Rules/Functions/data/bug-15168.php + CallToFunctionParametersRuleTest::testBug15168() — the eight analogous parameter shapes listed above. Fails with ten argument.templateType errors without the fix.
  • tests/PHPStan/Analyser/nsrt/bug-15168.php — pins the inferred types, which the fix deliberately leaves alone (Assert::notNull(null) stays mixed, Arrays::removeNull([null, null]) stays array<mixed>), and covers the TemplateTypeMap change by asserting that T absorbed in one parameter still resolves from another.

Fixes phpstan/phpstan#15168

…her union member

- Add `PHPStan\Type\Generic\AbsorbedTemplateArgumentType`, an `ErrorType`
  subclass marking a template that a call left unresolved on purpose. Being
  an `ErrorType` it keeps behaving as unresolved everywhere a type gets
  materialized, so inferred types are unchanged.
- `UnionType::inferTemplateTypes()` binds its naked template members to that
  marker when every received type was absorbed by another member of the
  union, i.e. when `T|null` receives `null` and `T` had nothing to infer from.
  `getNakedTemplateType()` extracts the member-is-a-template test that the
  fallback loop already did, so `class-string<T>` is covered too.
- `TemplateTypeMap::union()`/`benevolentUnion()` let any real inference win
  over the marker, so `f(T|null $a, T $b)` called with `(null, $o)` still
  resolves `T` to the second argument.
- `FunctionCallParametersCheck` skips the marker next to the existing
  explicit-`NeverType` carve-out for unfilled variadics.
- Removes the two `Service::collection()` expectations added with the
  absorption change in dc41d1e - they are the reported `@param T|null` /
  `@return T` shape.

The same false positive was reproduced and fixed for `T|false`, `T|int|float`,
`list<T|null>`, `iterable<T|null>`, `array{T|null}`, `callable(): (T|null)`,
`T|null ...$variadic` and `class-string<T>|null`. All report sites of the
message (methods, static methods, functions, callables, `call_user_func`,
instantiations, attributes) route through `FunctionCallParametersCheck`, so
one carve-out covers them all. `array<T>|null` receiving `array{}` keeps
reporting - the union member holding `T` is not naked, so nothing absorbed it.
@ondrejmirtes
ondrejmirtes merged commit c4c035e into phpstan:2.2.x Sep 3, 2026
797 of 809 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-949trp0 branch September 3, 2026 17:05
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