Skip to content

fix(types): the native static return type of a static method is the called class - #13374

Closed
jirkace wants to merge 2 commits into
vimeo:masterfrom
jirkace:fix/7485-native-static-return
Closed

jirkace wants to merge 2 commits into
vimeo:masterfrom
jirkace:fix/7485-native-static-return

Conversation

@jirkace

@jirkace jirkace commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Psalm gives a static method that is declared : static the parent's class instead of the called class when it is called as static::, self:: or Child::, so a property of the child is "not defined".

Before

abstract class ParentException extends Exception
{
    public static function withMessage(string $message): static
    {
        return new static($message);
    }
}

class MyException extends ParentException
{
    private int $user = 0;

    public static function create(string $message, int $user): static
    {
        $exception = static::withMessage($message);
        $exception->user = $user; // UndefinedPropertyAssignment: Instance property ParentException::$user is not defined

        return $exception;
    }
}

After

        $exception->user = $user; // no issue ($exception is MyException&static)

Refs #7485

@jirkace

jirkace commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Behaviour change

  • A native : static return type is scanned as Parent&static (class of the declaring class, is_static). TypeExpander::expandNamedObject() only replaced the static of a docblock @return static (value static) with the class the method is called on, so for a native return type called through static::, self::, parent:: or a class name from a child, the result stayed Parent&static. A new branch resolves such a type to the called class (Child&static) when it is given as a class name that is, or extends, the declaring class; instance calls already did.
  • Visible effects: no UndefinedPropertyAssignment, LessSpecificReturnStatement or MoreSpecificReturnType for code like the above (the report of Invalid InvalidPropertyAssignmentValue when assigning value to a private property #7485, also with self::withMessage()); $x = static::make() traces C&static instead of P&static, as it did for a docblock @return static before.
  • Verified: the new valid case fails on master (UndefinedPropertyAssignment) and passes here; the neighbouring single cases with static/fluent/self in their names (up to 12 per file in MethodCallTest, ReturnTypeTest, ClassTest, ClassTemplateTest) all pass (the loop printed no failure; I did not record how many it ran); phpcs and full self-analysis are clean; the psalm/endtoend-test-phpunit run with its .psalm/config.xml reports no error. Not run: the rest of the test suite and the perf report.

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

✅ Performance: unchanged · Issues: decreased

Project Memory (MB) Wall (s) Issues Weighted
✅ laravel 1 821 63.9 $\color{gray}{\textsf{+0.2\%}}$ 25 916 $\color{red}{\textsf{+1}}$ $\color{green}{\textsf{-2}}$ 51 156 $\color{green}{\textsf{-3}}$
✅ symfony 4 002 131.7 $\color{gray}{\textsf{+2.1\%}}$ 51 065 $\color{gray}{\textsf{+0}}$ $\color{green}{\textsf{-16}}$ 101 056 $\color{green}{\textsf{-48}}$
✅ doctrine-orm 428 13.2 $\color{gray}{\textsf{+1.7\%}}$ 3 505 $\color{gray}{\textsf{+0}}$ $\color{green}{\textsf{-2}}$ 5 574 $\color{green}{\textsf{-6}}$
✅ psl 651 17.3 $\color{gray}{\textsf{+0.3\%}}$ 1 424 5 514
✅ phpunit 506 10.4 $\color{gray}{\textsf{-0.7\%}}$ 1 627 1 265
✅ php-parser 245 9.5 $\color{gray}{\textsf{-0.3\%}}$ 17 169 20 624
✅ tempest 922 20.1 $\color{gray}{\textsf{-6.1\%}}$ 5 016 9 616
✅ amphp-amp 60 1.4 $\color{gray}{\textsf{-0.7\%}}$ 32 79
✅ webmozarts-assert 272 3.0 577 2 714
✅ guzzle-promises 55 1.4 $\color{gray}{\textsf{-0.7\%}}$ 163 407
✅ monolog 161 3.5 $\color{gray}{\textsf{+1.2\%}}$ 1 004 2 722
✅ drupal 5 196 146.1 $\color{gray}{\textsf{+0.6\%}}$ 98 829 $\color{gray}{\textsf{+0}}$ $\color{green}{\textsf{-1}}$ 161 671 $\color{green}{\textsf{-4}}$
Total 14 319 421.3 $\color{gray}{\textsf{+0.6\%}}$ 206 327 $\color{red}{\textsf{+1}}$ $\color{green}{\textsf{-21}}$ 362 398 $\color{green}{\textsf{-61}}$
How to read this table
  • Memory: Psalm's own peak figure, threads=1. More than +5 % fails, unless the PR changes composer.json.
  • Wall: base and head run on the same runner; ±10 % is noise. Above +20 % warns, never fails. re-run: a reversed second run was taken and the faster kept.
  • Issues: head count, then $\color{red}{\textsf{+added}}$ $\color{green}{\textsf{-removed}}$ against base.
  • Weighted: issues weighted by error level (levels 1–8 as is, always-errors 10, feature-specific and purity suggestions 0). The projects' own code is pinned and their dependencies resolve identically for base and head, so a change means Psalm reports more, fewer or differently serious issues. A rise is not necessarily a regression.
  • Total: sum over the projects compared on both sides. Thresholds apply per project, not to the total.
  • Colors: $\color{red}{\textsf{red}}$ past a fail or warn threshold (or more issues), $\color{green}{\textsf{green}}$ an improvement past noise (or fewer issues), $\color{gray}{\textsf{gray}}$ noise.
  • Performance: regressed if a head run crashed or timed out or a memory or wall delta is red, else improved if one is green, else unchanged. Its icon is the check result: ❌ fail · ⚠️ warn · ✅ pass; the icon next to each project is that project's result, 💥 = not comparable: base crashed or timed out (counts as ⚠️).
  • Issues (heading): the total weighted change, increased · decreased · unchanged. Informational, never fails the check.
✅ laravel: issues +1 −2

Memory 1 821 → 1 821 MB (<0.1 %) · Wall 63.8 → 63.9 s (+0.2 %)

Weighted 51 159 → 51 156 (−3, −0.01 %): L3 −1

Issue type added removed
LessSpecificReturnStatement 0 1
MoreSpecificReturnType 1 1

Added (1)

  • src/Illuminate/Database/Eloquent/HasCollection.php:24 MoreSpecificReturnType (error): The declared return type 'Illuminate\Database\Eloquent\Collection<array-key, Illuminate\Notifications\DatabaseNotification&static>' for Illuminate\Database\Eloquent\HasCollection::newCollection is more specific th…

Removed (2)

  • src/Illuminate/Http/Request.php:565 MoreSpecificReturnType (error): The declared return type 'Illuminate\Http\Request&static' for Illuminate\Http\Request::duplicate is more specific than the inferred return type 'Symfony\Component\HttpFoundation\Request&static'
  • src/Illuminate/Http/Request.php:570 LessSpecificReturnStatement (error): The type 'Symfony\Component\HttpFoundation\Request&static' is more general than the declared return type 'Illuminate\Http\Request&static' for Illuminate\Http\Request::duplicate
✅ symfony: issues +0 −16

Memory 4 002 → 4 002 MB (<0.1 %) · Wall 129.0 → 131.7 s (+2.1 %)

Weighted 101 104 → 101 056 (−48, −0.05 %): L3 −16

Issue type added removed
LessSpecificReturnStatement 0 8
MoreSpecificReturnType 0 8

Removed (16)

  • src/Symfony/Component/String/AbstractUnicodeString.php:485 MoreSpecificReturnType (error): The declared return type 'Symfony\Component\String\AbstractUnicodeString&static' for Symfony\Component\String\AbstractUnicodeString::trimPrefix is more specific than the inferred return type 'Symfony\Componen…
  • src/Symfony/Component/String/AbstractUnicodeString.php:488 LessSpecificReturnStatement (error): The type 'Symfony\Component\String\AbstractString&static' is more general than the declared return type 'Symfony\Component\String\AbstractUnicodeString&static' for Symfony\Component\String\AbstractUnicod…
  • src/Symfony/Component/String/AbstractUnicodeString.php:524 MoreSpecificReturnType (error): The declared return type 'Symfony\Component\String\AbstractUnicodeString&static' for Symfony\Component\String\AbstractUnicodeString::trimSuffix is more specific than the inferred return type 'Symfony\Componen…
  • src/Symfony/Component/String/AbstractUnicodeString.php:527 LessSpecificReturnStatement (error): The type 'Symfony\Component\String\AbstractString&static' is more general than the declared return type 'Symfony\Component\String\AbstractUnicodeString&static' for Symfony\Component\String\AbstractUnicod…
  • src/Symfony/Component/String/UnicodeString.php:204 MoreSpecificReturnType (error): The declared return type 'Symfony\Component\String\UnicodeString&static' for Symfony\Component\String\UnicodeString::join is more specific than the inferred return type 'Symfony\Component\String\AbstractUnicodeString…
  • src/Symfony/Component/String/UnicodeString.php:209 LessSpecificReturnStatement (error): The type 'Symfony\Component\String\AbstractUnicodeString&static' is more general than the declared return type 'Symfony\Component\String\UnicodeString&static' for Symfony\Component\String\UnicodeString::join
  • src/Symfony/Component/String/UnicodeString.php:288 MoreSpecificReturnType (error): The declared return type 'Symfony\Component\String\UnicodeString&static' for Symfony\Component\String\UnicodeString::replaceMatches is more specific than the inferred return type 'Symfony\Component\String\AbstractUni…
  • src/Symfony/Component/String/UnicodeString.php:293 LessSpecificReturnStatement (error): The type 'Symfony\Component\String\AbstractUnicodeString&static' is more general than the declared return type 'Symfony\Component\String\UnicodeString&static' for Symfony\Component\String\UnicodeString::replaceM…
  • src/Symfony/Component/String/UnicodeString.php:402 MoreSpecificReturnType (error): The declared return type 'Symfony\Component\String\UnicodeString&static' for Symfony\Component\String\UnicodeString::trimPrefix is more specific than the inferred return type 'Symfony\Component\String\AbstractUnicode…
  • src/Symfony/Component/String/UnicodeString.php:428 LessSpecificReturnStatement (error): The type 'Symfony\Component\String\AbstractUnicodeString&static|Symfony\Component\String\UnicodeString&static' is more general than the declared return type 'Symfony\Component\String\UnicodeString&static' for Sy…
  • … and 6 more
✅ doctrine-orm: issues +0 −2

Memory 428 → 428 MB (<0.1 %) · Wall 13.0 → 13.2 s (+1.7 %)

Weighted 5 580 → 5 574 (−6, −0.11 %): L3 −2

Issue type added removed
LessSpecificReturnStatement 0 1
MoreSpecificReturnType 0 1

Removed (2)

  • src/Mapping/ToOneInverseSideMapping.php:35 MoreSpecificReturnType (error): The declared return type 'Doctrine\ORM\Mapping\ToOneInverseSideMapping&static' for Doctrine\ORM\Mapping\ToOneInverseSideMapping::fromMappingArrayAndName is more specific than the inferred return type 'Doctrine\ORM\Mapping\As…
  • src/Mapping/ToOneInverseSideMapping.php:50 LessSpecificReturnStatement (error): The type 'Doctrine\ORM\Mapping\AssociationMapping&static' is more general than the declared return type 'Doctrine\ORM\Mapping\ToOneInverseSideMapping&static' for Doctrine\ORM\Mapping\ToOneInverseSideMapping::fromMapping…
✅ drupal: issues +0 −1

Memory 5 196 → 5 196 MB (<0.1 %) · Wall 145.2 → 146.1 s (+0.6 %)

Weighted 161 675 → 161 671 (−4, <0.01 %): L4 −1

Issue type added removed
ImplementedReturnTypeMismatch 0 1

Removed (1)

  • core/lib/Drupal/Core/Entity/Query/ConditionFundamentals.php:95 ImplementedReturnTypeMismatch (error): The inherited return type 'Drupal\Core\Entity\Query\ConditionInterface&static' for Drupal\Core\Entity\Query\ConditionInterface::setQuery is different to the implemented return type for Drupal\Core\…

head 3ee5e5e · base b0cb649 (merge-base) · workflow run

@jirkace

jirkace commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Behaviour change (follow-up commit: the failing unit-test shards, 6/8 on all PHP versions)

  • ClosureTest::FirstClassCallable:StaticMethod:ClassTemplatesAndStatic expected static::make(...) and self::make(...), called inside Ints extends Collection, to be Closure():(Collection&static), i.e. the declaring class of the native : static return type. That is the behaviour this PR changes: the closure created from the call now returns Ints&static, like the call static::make() itself. The two @psalm-check-type-exact expectations are changed to Closure():(Ints&static); the Ints::make(...)/FinalInts::make(...) assertions of the same case were already right and still pass.
  • Verified: that case passes, as does PartialApplication:StaticReturnOfANonFinalReceiver (which already expected P&static for static::create(?)) and the other Static/Partial/FirstClass single cases of ClosureTest and PartialFunctionApplicationTest (the loop printed no failure). Not run: the rest of the shards (CI will tell).

@orklah

orklah commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator

Can you rebase and check interactions with merged #12643

jirkace and others added 2 commits November 8, 2026 14:28
…alled class vimeo#7485

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>


Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@jirkace

jirkace commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

@orklah Rebased onto master (new head 3ee5e5e). The only conflict was in tests/MethodCallTest.php, where master added mysqliGetWarningsMayReturnFalse at the same spot; both entries are kept.

About #12643: it is not merged yet (still open). It fixes the same behaviour as this PR and #13401. This branch passes the new valid cases of #12643 and #13401, and #12643 passes this PR's case, so only one of them is needed. Merging this with #13401 conflicts only in the test file: both TypeExpander branches give the same result, so they don't conflict. This PR only rewrites a plain TNamedObject return type, so it doesn't touch static<...> generics, which is where #12643 regressed.

@orklah orklah closed this Nov 8, 2026
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