Skip to content

Intersection types #637

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft

Intersection types #637

wants to merge 1 commit into from

Conversation

Nek-
Copy link
Contributor

@Nek- Nek- commented Apr 17, 2025

⚠️ Nothing definitive here. It's just a draft to know if the team is ok with this kind of design.

Note about the design: I created a new sub-api for type management because the current one is not compatible with intersections but needs to exist to keep backward compatibility.

What still needs to be done:

  • Fix existing bugs/tests with this implementation (god it's hard to fix)
  • Add deprecation about the old API (some comments already existing in the code will help!)
  • Refactor to remove deprecated calls
  • Implement code generation with intersection
  • Add tests regarding intersection
  • Implement intersection support & dnf for methods args

Please have a look and tell me if this kind of implementation would be ok for you. Thanks!

Stands as replacement for #569 and should fix #535 and #558

$node->setReturnTypeNode(new ReturnTypeNode(...$returnTypes));
// Tentative return types also need reflection
$returnReflectionType = $method->getTentativeReturnType();
\assert($returnReflectionType !== null);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think those asserts are useless (the $returnReflectionType variable will still be nullable anyway as there is no else branch assigning it)

{
if ($type instanceof ReflectionIntersectionType) {
foreach ($type->getTypes() as $innerReflectionType) {
$innerTypes[] = new SimpleType($innerReflectionType->getName());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this missing the resolution of the type name ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this should use createTypeFromReflection for the inner type as well.

return $this->types !== ['void' => 'void']
&& $this->types !== ['never' => 'never'];
if ($this->type === null) {
return false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be true

/**
* @param string|TypeInterface ...$types
*/
public function __construct(string|TypeInterface ...$types)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't the new API allows null as well ?

Copy link
Contributor Author

@Nek- Nek- Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep the new API will be null|string|TypeInterface $type = null, you're absolutely right

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then this needs to be updated to support passing null

}

public function canUseNullShorthand(): bool
{
return isset($this->types['null']) && count($this->types) === 2;
if ($this->type instanceof UnionType) {
return $this->type->has(new SimpleType('null')) && count($this->type->getTypes()) === 2;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the union contains an intersection type as its other type ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not see any problem here but code generation needs an update for sure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah true. We can use the null shorthand on an intersection. I forgot that.

throw new DoubleException('Type cannot be nullable true');
}
}

if (\PHP_VERSION_ID >= 80000 && isset($this->types['mixed']) && count($this->types) !== 1) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why removing this ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question is more like why is this method guardIsValidType still here. I will remove it on cleanup. The UnionType checks this mixed-thing already.

return $this->prefixWithNsSeparator($type);
}
}

/**
* @todo: put this in SimpleType
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cannot really go in SimpleType, as some of those are about the full type, not each simple type used in a union

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess each type will have its own exceptions, this still needs to be done.

return $this->prefixWithNsSeparator($type);
}
}

/**
* @todo: put this in SimpleType
* @return void
*/
protected function guardIsValidType()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be careful. ReturnTypeNode extends this class to override this method, but you still use $this->types in it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After cleanup this method should not even exist anymore here.


namespace Prophecy\Doubler\Generator\Node\Type;

class SimpleType implements TypeInterface, \Stringable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think TypeInterface should actually extend Stringable to force all types to be castable to string (and documenting that we expect their string representation to be usable in a namespaced context)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had this idea at the begining but I ended up with this because I don't want to make code generation in tostring methods. Actually I think I will remove stringable even from here.

return 'int';

// built in types
case 'self':
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self (and parent) should be resolved to the actual class name before creating the type from reflection, to avoid issues (Prophecy will use those types to generate methods in a child class, where those keywords would have a different meaning, which would break in parameter types)

@Nek- Nek- force-pushed the intersection-types branch from 5250a7c to ebb7746 Compare April 25, 2025 23:51
@Nek-
Copy link
Contributor Author

Nek- commented Apr 26, 2025

Thank you for your review @stof . And good news! I fixed the bugs in my previous implementation. (some you noticed in your review!) Keeping backward compatibility has been a real pain! But it WORKS. (test suite green 100%)

It's currently a fully working PR for intersection type.... But only for return types!

Some work needs to be done for cleaning... But it also needs php to be bumped to 8.1+ (which would probably be better in another PR), and a lot of cleaning.

But for now, since it's a lot of work and personal investment, I'd like you to tell me if the implementation is ok before working on it again.

@Nek- Nek- force-pushed the intersection-types branch from e0672c9 to facea20 Compare April 26, 2025 12:50
@Nek- Nek- changed the title Implement intersection types Intersection types Apr 26, 2025

namespace Prophecy\Doubler\Generator\Node\Type;

class SimpleType implements TypeInterface, \Stringable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To simplify code generation, I think we should have 2 separate Type classes for that:

  • BuiltinType to represent built-in types
  • ObjectType to represent object-based types, taking a class name as argument (and prefixing it with a \ when rendering it to be compatible with namespaced context, but not when returning it in a getClass method so that we can compare that getter to Foo::class if needed, unlike the current getType method)

Both classes can of course implement a SimpleType interface containing a method returning the code representation of the type. But then, I think it could make sense to have that method returning such code representation be part of TypeInterface and let UnionType and IntersectionType implement it (basically, using the logic you have in the ClassCodeGenerator for now)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having an ObjectType and BuiltinType looks over-engineered to me (a lot of code for basically the same thing). It also involve a factory in the middle making the distinction between the 2 cases (in classmirror).

About the problem you mention, it would be possible to normalize the type only in the toString method, isn't it?

I understand that having the 2 types makes sense. I actually like pretty much the idea, I just worry of the "why" and "isn't it just more code for more code".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClassMirror could create the right type based on ReflectionNamedType::isBuiltIn(), which would also mean we automatically get support for new builtin types for newer PHP versions instead of having to hardcode which strings are a built-in type that need to produce a different code representation (classes need to be prefixed with \ to be used in a namespaced context while built-in type must not be prefixed).

use Prophecy\Exception\Doubler\DoubleException;

abstract class TypeNodeAbstract
{
/** @var array<string, string> */
protected $types = [];
protected TypeInterface|null $type;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest making the new property private (if child classes need to read it, there is a public getter they can use)

/**
* @param string|TypeInterface ...$types
*/
public function __construct(string|TypeInterface ...$types)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then this needs to be updated to support passing null

}

public function canUseNullShorthand(): bool
{
return isset($this->types['null']) && count($this->types) === 2;
if ($this->type instanceof UnionType) {
return $this->type->has(new SimpleType('null')) && count($this->type->getTypes()) === 2;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah true. We can use the null shorthand on an intersection. I forgot that.


if ($type instanceof UnionType) {
return join('|', array_map(
fn (TypeInterface $type) => $this->generateSubType($type),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic does not support DNF types. Parenthesis are required around the intersection types used in a union type.

@Nek- Nek- force-pushed the intersection-types branch from facea20 to 55a06b0 Compare May 14, 2025 18:47
$this->builtin = true;
switch ($type) {
// type aliases
case 'double':
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

double is not a builtin alias in PHP. It is treated as a class name (and triggers a warning). Same for the others: https://3v4l.org/T9iRf

I think the new API should not have those special cases (the BC layer in TypeNodeAbstract should handle it instead, and triggering an additional deprecation saying that BuiltinType in the new API will not support such aliases and they should use the proper value instead)

namespace {
class CustomClass extends \stdClass implements {

public function foo(): \Foo&\Bar|string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a valid DNF type in the code: https://3v4l.org/aivA5 (I just removed the implements that is invalid code, as our ClassCodeGenerator apparently does not support the case of an empty list of interface, which is not an issue for Prophecy itself as we always add the ProphecySubjectInterface)

$code->shouldBe($expected);
}

function it_generates_proper_code_for_intersection_return_types(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest having separate tests for an intersection return type (what the current name says) and for a DNF type (what it does)

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.

Intersection types in generated code
2 participants