Skip to content
Open
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
15 changes: 13 additions & 2 deletions src/docblock/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1297,8 +1297,19 @@ pub fn should_override_type_typed(docblock_type: &PhpType, native_type: &PhpType

// Unwrap nullable wrappers for further analysis. `?Foo` → `Foo`,
// `Foo|null` → `Foo`. For non-nullable types, use as-is.
let doc_inner = docblock_type.unwrap_nullable();
let native_inner = native_type.unwrap_nullable();
//
// `non_null_type()` strips nullability from BOTH representations — the
// `?Foo` (`Nullable`) form and the `Foo|null` (`Union` with a `null`
// member) form. Plain `unwrap_nullable()` only handled the former, so a
// nullable-union native such as `object|null` reached the union branch
// below with its `null` member still attached. Since `object` and `null`
// are both "scalar names", that branch then judged the whole type
// unrefinable and discarded a generic docblock return like
// `@psalm-return ?T`, leaving the bare native (`object|null`).
let doc_owned = docblock_type.non_null_type();
let doc_inner = doc_owned.as_ref().unwrap_or(docblock_type);
let native_owned = native_type.non_null_type();
let native_inner = native_owned.as_ref().unwrap_or(native_type);

// If the docblock type is a bare, unparameterised primitive scalar
// (`int`, `string`, `bool`, etc.), there's no value in overriding.
Expand Down
61 changes: 61 additions & 0 deletions tests/phpstan_nsrt/inherited-generic-return-nullable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php declare(strict_types = 1);

// Regression: a class-level generic return type whose method has a *nullable*
// native return hint (e.g. `object|null`) must still be resolved through the
// `@extends`/`@psalm-return` template binding, not collapse to the native hint.
//
// This is the Doctrine `ServiceEntityRepository<T>::find(): ?T` shape: before
// the fix, `should_override_type_typed` analysed `object|null` with its `null`
// member attached, judged it unrefinable (both `object` and `null` are "scalar
// names"), and discarded the generic docblock return — so `$repo->find()`
// resolved to `object|null` instead of `Entity|null`.

namespace InheritedGenericReturnNullable;

use function PHPStan\Testing\assertType;

class Entity {}

/**
* @template T of object
*/
class EntityRepository
{
/**
* @return object|null
* @psalm-return ?T
*/
public function find(mixed $id): object|null {}
}

/**
* @template T of object
* @template-extends EntityRepository<T>
*/
class ServiceEntityRepository extends EntityRepository {}

/** @extends ServiceEntityRepository<Entity> */
class EntityRepo extends ServiceEntityRepository {}

/** @extends EntityRepository<Entity> */
class DirectRepo extends EntityRepository {}

/** @template V */
class CollectionNonNull
{
/** @return V */
public function get(): object {}
}

/** @extends CollectionNonNull<Entity> */
class EntityCollection extends CollectionNonNull {}

function t(EntityRepo $multi, DirectRepo $single, EntityCollection $coll): void
{
// Two-level @extends (Doctrine's exact shape): native object|null + @psalm-return ?T
assertType('Entity|null', $multi->find(1));
// Single-level @extends: native object|null + @psalm-return ?T
assertType('Entity|null', $single->find(1));
// Control: non-nullable native object + @return V already worked before the fix
assertType('Entity', $coll->get());
}
Loading