Skip to content

Bidirectional type narrowing for generic types (would close an obscure hole in type checking!) #6732

@00dani

Description

@00dani

Feature request

Currently an assignment of the form$this->attribute = $value narrows the type of $this->attribute to the same type as $value for the remainder of the method. In most cases, this makes sense.

For example, the following code is statically sound. Iterables don't always have a forAll method, but there's no possible way $this->stuff can be of a type other than ArrayCollection when we attempt to call the forAll method.

/** @var iterable<int, Thing> */
private iterable $stuff;

public function __construct() {
  $this->stuff = new ArrayCollection();
  $this->stuff->forAll($doAThing);
}

However, a problem arises if the type of $value is in some way wider than the type of $this->attribute. This is only possible when generic types are used: $this->attribute might have narrower type parameters than $value does, although $value might have a more specific class type. Since the assignment only affects the inferred type of $this->attribute, it actually widens those type parameters.

I demonstrated an example of this problem a few minutes ago, which I'll reproduce here with dumpType information included:

/** @var Collection<int, int> $ints */
public iterable $ints;

/** @var Collection<int, string> $strings */
public iterable $strings;

public function __construct() {
	dumpType($this->ints); // Doctrine\Common\Collections\Collection<int, int>
	dumpType($this->strings); // Doctrine\Common\Collections\Collection<int, string>
	$array = new ArrayCollection();
	dumpType($array); // Doctrine\Common\Collections\ArrayCollection<*NEVER*, *NEVER*>
	$this->ints = $array;
	dumpType($this->ints);  // Doctrine\Common\Collections\ArrayCollection<*NEVER*, *NEVER*>
	$this->strings = $array;
	dumpType($this->strings); // Doctrine\Common\Collections\ArrayCollection<*NEVER*, *NEVER*>
}

As you can see, PHPStan "forgets" that $this->ints and $this->strings have different type parameters. This leads to an admittedly-minor hole in the type system, since this mechanism may be used to statically cast from any type to any other, without a corresponding runtime conversion occurring.

In a situation like this, I believe that PHPStan should effectively unify the types of $this->attribute and $value, in the logic programming sense. The inferred types of both variables ought to be narrowed by the assignment expression, preserving type compatibility for both the outer class type and its type parameters. In other words, this ought to happen:

dumpType($this->ints); // Doctrine\Common\Collections\Collection<int, int>
$array = new ArrayCollection();
dumpType($array);  // Doctrine\Common\Collections\ArrayCollection<*NEVER*, *NEVER*>
$this->ints = $array;
dumpType($this->ints); // Doctrine\Common\Collections\ArrayCollection<int, int>
dumpType($array); // Doctrine\Common\Collections\ArrayCollection<int, int>

This narrowing step would cause the subsequent assignment, $this->strings = $array, to fail type-checking: a Collection<int, int> cannot be assigned to a variable of type Collection<int, string>. The hole in the type system is sealed.

I do think this is an unlikely situation, since one would typically write $this->ints = new ArrayCollection() in the first place rather than using a temporary variable. Additionally, Psalm has the exact same type system hole at time of writing. Still, this is an issue worth being aware of. Also, narrowing bidirectionally would allow the constructor to go ahead and populate $this->ints in a properly typesafe way, which currently doesn't occur.

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

Yes! I've been experimenting with API Platform at work, since we're hoping to adopt it for newly planned projects, and API Platform's very structured design has turned out to mesh wonderfully with PHPStan's type checking. As a Haskell fan I generally love powerful type systems, and PHPStan has been giving me those warm fuzzy TypeScript feels despite working in PHP. 🐱

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions