Skip to content

Inherited generic return type is discarded when the method's native hint is nullable (e.g. Doctrine ServiceEntityRepository<T>::find(): ?Tobject|null) #151

@MrSrsen

Description

@MrSrsen

PHPantom version

phpantom_lsp 0.8.0 (also reproduces on main @ 669b0e1)

Installation method

Pre-built binary from GitHub Releases

Operating system

Linux x86_64

Editor

Neovim

Bug description

A class-level generic return type (@return T / @psalm-return ?T) that should be resolved through an @extends Parent<Concrete> binding is dropped — the call resolves to the bare native hint instead — whenever the inherited method's native return type is a nullable union like object|null.

The same setup with a non-nullable native hint (object) works correctly, so the trigger is specifically the nullability of the native return type, not the generic machinery itself (which is otherwise fine).

This is exactly Doctrine's ServiceEntityRepository<T>::find(): ?T shape (@return object|null + @psalm-return ?T, native hint object|null): $repo->find() resolves to object|null instead of Entity|null, so the result is effectively untyped — no completion on it, and find-references on common methods called on it (getTitle(), getId(), …) become name-based and over-broad.

Expected: $repo->find() resolves to Entity|null.
Actual: $repo->find() resolves to object|null (the generic ?T is discarded).

Steps to reproduce

Single file is enough (any composer.json PSR-4 autoload, or even a single file in the workspace root). No vendor/ needed.

<?php

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

class Entity
{
    public function name(): string {}
}

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

function test(EntityRepo $repo): void
{
    $e = $repo->find();
    // Hover on $e  ->  expected: Entity|null   |   actual: object|null
    $e->name();      // `name` is not completed/resolved, because $e is `object|null`
}
  1. Open the file in your editor.
  2. Hover on $e (the result of $repo->find()).
  3. Expected: Entity|null. Actual: object|null.
  4. Completion on $e-> does not offer Entity's members.

Contrast — the non-nullable version resolves correctly, which pinpoints the trigger:

/** @template T */
class Box
{
    /** @return T */
    public function get(): object {}   // native hint `object` (non-nullable) -> works
}

/** @extends Box<Entity> */
class EntityBox extends Box {}

// $box->get()  ->  correctly resolves to `Entity`

The bound (T of object vs T) and inheritance depth don't matter — single-level @extends Repo<Entity> with a nullable native hint is enough. Multi-level (Repo -> ServiceEntityRepository<T> -> EntityRepository<T>, i.e. real Doctrine) is also affected.

Error output or panic trace

No crash and no error output — purely incorrect type inference.

.phpantom.toml

default / no config file

Additional context

PR with fix: #152

Root cause (traced in source): src/docblock/tags.rs::should_override_type_typed decides native-vs-docblock return type using PhpType::unwrap_nullable(), which only strips the ?Foo (Nullable) representation — not the Foo|null (Union with a null member) representation. So a nullable-union native like object|null reaches the "union" branch with its null member still attached. Since both object and null are in is_scalar_name, that branch (members.iter().any(|m| !m.is_scalar())) judges the whole type unrefinable, returns false, and the generic docblock return is discarded —leaving the bare native object|null. (The function's own comment already states the intent: Foo|null -> Foo.)

Instrumented to confirm: native="object" -> override=true, but native="object|null" -> override=false for the same ?T docblock.

Fix: use non_null_type() (which strips null from both the Nullable and the Union representations) instead of unwrap_nullable() when computing the inner types in should_override_type_typed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions