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 4 commits into
base: master
Choose a base branch
from
Draft

Intersection types #637

wants to merge 4 commits 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)

Copy link
Member

Choose a reason for hiding this comment

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

please remove those 2 useless asserts (as the $returnReflectionType variable is not used anymore in those if and elseif branches, but only later where we have to deal with null anyway)

{
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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My bad it should be new BuiltinType('null') in the new API.

Copy link
Member

Choose a reason for hiding this comment

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

I'm not talking about the case where of using null as return type, but of the case of not having a return type at all.

}

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, and fixed!

@Nek- Nek- force-pushed the intersection-types branch from facea20 to 55a06b0 Compare May 14, 2025 18:47
Nek- added 3 commits June 6, 2025 17:19
- Introduce new objects ObjectType and BuiltinType
- Fix implementation for self and static types
- Add final tags
- Fix some phpstan warnings
@Nek- Nek- force-pushed the intersection-types branch from 0e986a3 to 0e42341 Compare June 10, 2025 09:46
@Nek-
Copy link
Contributor Author

Nek- commented Jun 10, 2025

@stof I updated the PR following your insights, thank you for the detailed review. ⭐

I still need to track deprecated calls and add deprecation triggers, and I will do it promptly as soon as you validate that new implementation.

Copy link
Member

@stof stof left a comment

Choose a reason for hiding this comment

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

The general approach looks good. I still recommend changing the way we manage the source code representation of a given type.
Please start working on the deprecation layer as well.

@@ -33,13 +33,6 @@ function it_can_have_void_type()
$this->getTypes()->shouldReturn(['void']);
}

function it_will_normalise_type_aliases_types()
{
$this->beConstructedWith('double', 'real', 'boolean', 'integer');
Copy link
Member

Choose a reason for hiding this comment

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

should be kept as a legacy test, as the legacy API must keep normalizing them to avoid BC breaks.

$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.

please remove those 2 useless asserts (as the $returnReflectionType variable is not used anymore in those if and elseif branches, but only later where we have to deal with null anyway)

// Intersections cannot be composed of builtin types
$innerTypes[] = new ObjectType($innerReflectionType->getName());
}
return new IntersectionType($innerTypes);
Copy link
Member

Choose a reason for hiding this comment

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

what about ?Foo&Bar ? This is something that can exist (it came earlier than DNF types). It would be great to add a test covering such nullable intersection types in the ClassMirrorTest.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hum it does not seem to be supported with the current implementation. I will add a test for it. Nice catch!

$name = $this->resolveTypeName($type->getName(), $declaringClass);
if ($type->isBuiltin() || $name === 'static') {
$simpleType = new BuiltinType($name); // SimpleType constructor normalizes
} elseif ($name === 'self') {
Copy link
Member

Choose a reason for hiding this comment

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

resolveTypeName already handles the case of self

$this->types[$type] = $type;
}
$deprecation = 'Only 1 type will be supported in the future, strings are no longer supported as type.';
if (count($types) === 1 && $types[0] instanceof TypeInterface) {
Copy link
Member

Choose a reason for hiding this comment

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

we should accept count($types) === 1 && $types[0] === null as well, to assign null in the property

case 'integer':
return 'int';
Copy link
Member

Choose a reason for hiding this comment

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

We need to keep this normalization logic for BC reasons

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 agree but I misinterpreted one of your previous comment. I will change this back.

namespace Prophecy\Doubler\Generator\Node\Type;

/**
* @final
Copy link
Member

Choose a reason for hiding this comment

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

Can we make it actually final ?

Copy link
Member

Choose a reason for hiding this comment

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

if this is only about the mocking in specs, they should use actual instances, as those type classes are immutable value objects.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's also for its own spec

Copy link
Member

Choose a reason for hiding this comment

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

I don't think so. phpspec supports specing final classes AFAIK.


private function prefixWithNsSeparator(string $type): string
{
// Avoid double-prefixing if already prefixed
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 the new API should not deal with that, expecting the unprefixed class name to be passed instead (which might be guarded to avoid issues). The BC layer in TypeNodeAbstract would have to handle the BC layer.

{
public function __construct(string $type)
{
parent::__construct($this->prefixWithNsSeparator($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 separating the string representation from getType in ObjectType:

  • FQCN in PHP are not starting with \, so I would find it good for ObjectType->getType() to return the unprefixed one (allowing to do things like $objectType->getType() === SplFileInfo::class)
  • qualified usages in PHP are starting with a \ (to resolve them as absolute names rather than relative names), so we need a \ prefix in the __toString method returning the source code representation (usable in namespaced context)

For that, I suggest that SimpleType becomes an interface (extending TypeInterface) instead of an abstract class sharing implementation.

return $generatedType;
}

private function generateSubType(TypeInterface $type, bool $isDnf = false): string
Copy link
Member

Choose a reason for hiding this comment

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

I still vote for making TypeInterface extends Stringable, expecting TypeInterface::__toString to provide the source code representation of the 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.

Ok I will refactor this

@Nek-
Copy link
Contributor Author

Nek- commented Jun 10, 2025

I still recommend changing the way we manage the source code representation of a given type.

Please explain what you mean because I refactored precisely with your recommandation here (the "ObjectType" vs "BuiltinType" thing in particular)

@stof
Copy link
Member

stof commented Jun 10, 2025

@Nek- that's what I detailed more in #637 (comment) and #637 (comment)

@@ -559,15 +563,24 @@ public function it_can_not_double_an_enum(): void
}

#[Test]
public function it_can_not_double_intersection_return_types(): void
public function it_can_double_intersection_return_types(): void
{
if (PHP_VERSION_ID < 80100) {
Copy link
Member

Choose a reason for hiding this comment

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

looks like those version checks were not properly cleaned when bumping the min PHP version.

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