-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[TypeInfo] Enforce stricter types sanity to avoid edge cases #57943
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
Conversation
e88ea3f
to
d0d90c8
Compare
d0d90c8
to
cbd04ea
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR means that the nullable check becomes $t instanceof NullableTypeInterface && $t->isNullable()
instead of $t->isNullable()
. I don't think this is a DX improvement.
if (\count($types) < 2) { | ||
throw new InvalidArgumentException(\sprintf('"%s" expects at least 2 types.', self::class)); | ||
} | ||
// Only accept non-composite object types, except the builtin 'object' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what about mixed
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fully agree about the interface, not sure why I finally decided to add it. Removed and re-added the methods to the Type
class (might have messed the diff for now, will fix it, sorry).
mixed
is not allowed as a member of an intersection. mixed
, never
and void
are standalone types in PHP, treated very differently from the others: they cannot be used in unions or intersections.
I agree we want to stay as flexible as possible, but we also want to avoid nightmarish edge cases like this current behaviour:
$type = Type::generic(Type::mixed(), Type::int());
dump($type->isNullable()); // true
$nonNullable = $type->asNonNullable();
dump($nonNullable->isNullable()); // true
/** | ||
* @return list<Type> | ||
*/ | ||
public function getTypes(): array; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what is the benefit of this interface ? The various composite types we have right now have different method for what the type composing them mean. So I don't see any use case for that interface.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's Mathias'. I just didn't copy/paste it from his PR and forgot to commit his comments/credits (fixed). You're incorrect, both composite types actually have common methods. I added them to the interface to make it clearer, and improved the docblocks. Basically, composite types provide methods to query their subtypes/parts.
/** | ||
* Whether this type can be used in unions. | ||
*/ | ||
public function isComposable(): bool |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The method name does not reflect what it does (using it in intersection would also be composing it)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are not allowed to use primitive types as members of an intersection, only class names.
However, I agree about the name/doc. I improved the description and refactored/renamed the method to isStandalone()
, flipping the logic (it now returns true
for mixed
, never
and void
.
This is meant to allow us to recognize standalone types (as the PHP engine calls them), as they are very specific.
@@ -19,17 +20,71 @@ | |||
* @author Mathias Arlaud <mathias.arlaud@gmail.com> | |||
* @author Baptiste Leduc <baptiste.leduc@gmail.com> | |||
* | |||
* @template T of Type | |||
* @template T of BuiltinType|ObjectType|GenericType|CollectionType|IntersectionType |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Restricting the bounds here without restricting them in the static factory method does not make sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. Sorry. Fixed.
* @param callable(T): bool $callable | ||
* @return list<T> | ||
*/ | ||
public function filter(callable $callable): array |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does filtering means in this context ? This is not clear as public API IMO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Improved the description. It basically calls array_filter($this->getTypes(), $callable)
. This is used to extract subtypes that satisfy a specific predicate:
// Only builtin types (mixed, int, etc...)
$type->filter(fn (Type $t) => $t instanceof BuiltinType);
// Only intersections
$type->filter(fn (Type $t) => $t instanceof IntersectionType);
// All integers, supporting generics like int<10,20>
$type->filter(fn (Type $t) => TypeIdentifier::INT === $t->getTypeIdentifier());
// All objects (including intersections, class types, generics and "object")
$type->filter(fn (Type $t) => TypeIdentifier::OBJECT === $t->getTypeIdentifier());
// All objects except "object"
$type->filter(fn (Type $t) => TypeIdentifier::OBJECT === $t->getTypeIdentifier() && !$t instanceof BuiltinType);
// All bools (thas is bool, true or false)
$type->filter(fn (Type $t) => $t->getTypeIdentifier()->isBool());
// Everything except "null"
$type->filter(fn (Type $t) => TypeIdentifier::NULL !== $t->getTypeIdentifier());
} | ||
|
||
public function getTypeIdentifier(): TypeIdentifier | ||
public function getBaseType(): BuiltinType|self |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would suggest avoiding to create useless diffs like that (by avoiding to swap methods). It makes git blame
a lot more useful.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, sorry, it's because I went back and forth. This is still a draft, I'll clean up everything at some point.
This is still experimental and I move methods or purposely break BC and like to keep related methods together, so this may happen again, although I will still try and avoid it, fully get your comment and apologize.
@stof Removing the interface got rid of my biggest BC break and made sure all the tests (including tests that are part of the Serializer) turned green again. That should allow us to determine whether the changes have any consequence on real world uses. |
This was originally supposed to try and improve how nullability is handled, but taking care of a couple more issues on the same branch really made my life easier. Hopefully it will be fine. At the end the main improvements are:
NullableTypeInterface
to ensure only types actually concerned with nullability have to deal with itgetTypeIdentifier
is now an abstract method onType
and every type consumer can rely on itThere are tests missing, but I'm pushing now so @stof and @mtarld can give me their comments.
Composite types validation
Intersections
Intersections can only contain class-types. Consequently, trying to create an intersection from the following types will now throw an exception:
new IntersectionType(Type::int(), Type::float())
new IntersectionType(Type::object('Foo'), Type::union(Type::object('Bar'), Type::object('Baz')))
(an union can still be made of intersections, but an intersection cannot contains unions, as per the DNF rules).Note: the tests were loaded with intersections of scalars, this one was fun to take care of. 🤨
Unions
Unions can be made of almost any other types, except unions (that's the logic that was already implemented). I only added an additional obvious test to prevent creating unions that include
void
ornever
.Other checks must be missing, but I'd be for following the PHP rules here and left it there for now.
Type::getTypeIdentifier()
getTypeIdentifier()
is now an abstract method ofType
. It is implemented as follow in children classes:BuiltinType
: unchangedObjectType
: returnsOBJECT
IntersectionType
: returnsOBJECT
UnionType
: if all subtypes have the same type identifier, return it. Otherwise,MIXED
This is already exploited by the composite type classes to allow the following:
UnionType::asNonNullable()
, by testing whetherTypeIdentifier::NULL === $type->getTypeIdentifier()
NullableTypeInterface
As I started to discuss on #57630, nullability is not a global concern as there are only 3 nullable cases:
null
,mixed
, and unions that contain a nullable type.Therefore, I moved
isNullable()
andasNonNullable()
to a newNullableTypeInterface
that is only implemented byBuiltinType
andUnionType
.I also fixed
Type::nullable()
andUnionType::asNonNullable()
to ensure they don't create new types if the current is already nullable (or not).