Skip to content

Finality of template argument inferred from new is breaking checks against covariant generic types #15166

Description

@sitepark-schaeper

Bug report

A type from a new expression is a final-marked class reflection. That final-ness then leaks into any @template-covariant generic type that is inferred from it, and the resulting covariant subtype check fails against the same type, which is not final-marked.

Here an example:

<?php declare(strict_types = 1);

class Model {}
class ExtendedModel extends Model {}

/** @template-covariant T of Model */
class Component {}

/** @extends Component<ExtendedModel> */
class ExtendedComponent extends Component {}

/** @template M of Model */
class ComponentBuilder
{
    /** @param M $model */
    public function __construct(public readonly Model $model) {}

    /** @param Component<M> $component */
    public function accept(Component $component): void {}
}

function fromParameter(ExtendedModel $model): void
{
    $builder = new ComponentBuilder($model);
    $builder->accept(new ExtendedComponent()); // all good
}

function fromNew(): void
{
    $model = new ExtendedModel();
    $builder = new ComponentBuilder($model);
    $builder->accept(new ExtendedComponent()); // Parameter #1 $component of method ComponentBuilder<ExtendedModel>::accept()
                                               // expects Component<ExtendedModel>, ExtendedComponent given.
                                               // 🪪  argument.type
}

The error message suggests, that the issue is the new ExtendedComponent() not beeing considered a Component<ExtendedModel> - however:

<?php
/** @var Component<ExtendedModel> $component */
$component = new ExtendedComponent();
$builder = new ComponentBuilder(new ExtendedModel());
$builder->accept($component); // Parameter #1 $component of method ComponentBuilder<ExtendedModel>::accept()
                              // expects Component<ExtendedModel>, Component<ExtendedModel> given.
                              // 🪪  argument.type

And this works:

<?php
/** @var ExtendedModel $model */
$model = new ExtendedModel();
$builder = new ComponentBuilder($model);
$builder->accept(new ExtendedComponent());

Other "fixes" include

  • final class ExtendedModel
  • M inferred from a natively typed parameter (like in fromParameter() above)
  • explicit /** @var ComponentBuilder<ExtendedModel> */
  • @template T of Model instead of @template-covariant

The new ExtendedModel() is typed via $classReflection->asFinal().
That is correct in itself - new X() is exactly X. But the final-marked ExtendedModel becomes the inferred value of M, so Component<M> resolves to Component<ExtendedModel-final>, while ExtendedComponent declares @extends Component<ExtendedModel> with a plain, non-final ExtendedModel. Under @template-covariant, PHPStan then asks whether ExtendedModel-final is a supertype of ExtendedModel, which it is not, because a non-final ExtendedModel value could be a subclass instance.

Code snippet that reproduces the problem

https://phpstan.org/r/839fffc8-aadb-4a78-ab15-e4e5e57fa374

Expected output

No errors. ExtendedComponent extends Component<ExtendedModel>, and $builder is ComponentBuilder<ExtendedModel> in both functions, so Component<M> is Component<ExtendedModel> in both cases.

Did PHPStan help you today? Did it make you happy in any way?

Absolutely! PHPStan is a wonderful piece of software - I can't imagine working with PHP without it.
I am very thankful for all the hard work you put into it

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions