Skip to content

[TypeInfo] Redesign Type methods and nullability #57630

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

Merged
merged 1 commit into from
Nov 13, 2024

Conversation

mtarld
Copy link
Contributor

@mtarld mtarld commented Jul 3, 2024

Q A
Branch? 7.2
Bug fix? no
New feature? yes
Deprecations? no
Issues
License MIT

By using the component and trying to integrate it within several libs, I ended up finding some limitations due to the component design.

Basically, the issues were:

  • Abstract classes (such as Type) knowing its concrete implementations (such as in getBaseType method).
  • For API simplicity CollectionType, and GenericType are forwarding methods to their wrapped type using __call, but I think this must be avoided as much as possible.
  • It is difficult to know if a type is "just" a nullable type (because we need to check the union content).
  • Invalid types could be instantiated

To tackle these issues, I propose the following:

  • Introduce CompositeTypeInterface and WrappingTypeInterface that describe whether a type is composed by several types or wrapping one.
  • Add a NullableType which extends UnionType and is a WrappingTypeInterface, so that it is easy to recognize a nullable type and to get the non-nullable related type.
  • Remove the Type::asNonNullable method as it's covered by NullableType::getWrappedType
  • Get rid of the __call method as it is easy to know it a type is wrapping another one
  • Rename is to satisfy and add the new CompositeTypeInterface::composedTypesSatisfy and WrappingTypeInterface::wrappedTypeSatisfy methods
  • Rename isA to isIdentifiedBy

Plus, thanks to @skalpa's great work, the following checks are added to prevent invalid type construction:

  • BackedEnumType
    • Backed type can only be int or string
  • CollectionType
    • Main type can only be array or iterable when it's a built-in type
  • GenericType
    • Main type can only be array or iterable when it's a built-in type
  • IntersectionType
    • Cannot contain other types than class types
  • UnionType
    • Cannot contain UnionType
    • Cannot contain standalone types (mixed, void, and never)
    • Cannot contain either true and false
    • Cannot contain either bool and true or false
    • Cannot contain either object and a class type

@stof
Copy link
Member

stof commented Jul 3, 2024

Using public properties instead of getters makes the maintenance harder in case we need to make the API of the component evolve (as we cannot rely on property hooks yet to implement BC layers)

@mtarld mtarld force-pushed the feat/type-specification branch from 1655aa6 to a9cc6dd Compare July 4, 2024 09:58
@mtarld mtarld changed the title [TypeInfo] Use specifications to get precise type information [TypeInfo] Redesign Type methods and nullability Jul 4, 2024
@mtarld mtarld force-pushed the feat/type-specification branch 3 times, most recently from be70dbe to d1d2368 Compare July 12, 2024 14:41
Copy link
Contributor

@Korbeil Korbeil left a comment

Choose a reason for hiding this comment

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

Loving all the changes ! 🚀

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.

In the current state of this PR, I'm voting -1 as it reduces the power of the component.

@skalpa
Copy link
Contributor

skalpa commented Aug 7, 2024

It is difficult to know if a type is "just" a nullable type (because we need to check the union content).

I am not sure I understand what problem your are trying to solve. A type is "just" a nullable type if $type instanceof BuiltinType && TypeIdentifier::NULL === $type->getTypeIdentifier().

Nevertheless, the changes feel a lot complex considering nullability is not a generic problem. There are only 3 kinds of nullable types: null, mixed, and unions that contain null.

Therefore, I would only keep the current isNullable(), and asNonNullable() methods, but refactor them a bit:

  • The implementation of Type::isNullable() should just return false
  • Type::asNonNullable() should be implemented and just return $this
  • All the unnecessary asNonNullable() in children classes can be removed
  • BuiltinType/UnionType have to override isNullable() and return true when appropriate

With these little changes I believe nullability would be properly covered.

@stof
Copy link
Member

stof commented Aug 7, 2024

@skalpa actually, there are more nullable types:

  • an intersection type where all its types are nullable
  • a union type containing a nullable type (which might not be null, as you said yourselves that there are more nullable types, for instance mixed or other union types)

@skalpa
Copy link
Contributor

skalpa commented Aug 8, 2024

@skalpa actually, there are more nullable types:

  • an intersection type where all its types are nullable
  • a union type containing a nullable type (which might not be null, as you said yourselves that there are more nullable types, for instance mixed or other union types)

@stof, intersections cannot contain nullable types, only class/interface names, which is logical (nothing could be both an instance of Stuff and null at the same time). Currently it is possible to create nonsensical intersections that would be invalid in PHP, but I fixed that in #57943.

For unions, yes, I meant "a union that contains a nullable" and not "a union that contains null", but after seeing your comment and checking, I believe it should be forbidden as well. Trying to add mixed to a union gives me PHP Fatal error: Type mixed can only be used as a standalone type.

And a union cannot contain another union: this throws an exception (Type::union() lets you pass other unions but it merges them to a new one before calling the constructor).

@stof
Copy link
Member

stof commented Aug 8, 2024

@skalpa as said above, mixed is nullable. And it could be used in an IntersectionType (unless you canonicalize such intersection mixed&T to be T during instantiation).

And why the PHP syntax enforces types to be written in disjunctive normal form (meaning no union inside intersection), I'm not sure TypeInfo should have this restriction on the created object graph (or it should have proper types enforcing valid usages).
And enforcing canonical forms might make asNonNullable more complex because of the fact that turning mixed into a non-nullable type turns it into a union.

@skalpa
Copy link
Contributor

skalpa commented Aug 8, 2024

@skalpa as said above, mixed is nullable. And it could be used in an IntersectionType (unless you canonicalize such intersection mixed&T to be T during instantiation).

@stof, No, an intersection cannot contain mixed. I am sorry to insist, but this is absolutely crucial as it completely changes the semantics of the type if not enforced. In PHP, intersections represent objects. If a value satisfies an intersection, then one must be able to assume that true === is_object($value). And PHP forbids using anything else than class names in intersections.

While the normal form is something else, I really believe we should prevent the creation of nonsensical and/or invalid types that would never be satisfied by any value or would only result in PHP fatal errors if used, as they may probably be indicators of a bug in user code.

If this is not enforced by the component, then as a user I will have to anticipate absolutely impossible cases just because the API allows them and I'd have to protect my code against intersections of scalars, or unions of mixed and never. ☹️

(even though yes, technically you're right, any intersection is always equivalent to itself & mixed)

And why the PHP syntax enforces types to be written in disjunctive normal form (meaning no union inside intersection), I'm not sure TypeInfo should have this restriction on the created object graph (or it should have proper types enforcing valid usages). And enforcing canonical forms might make asNonNullable more complex because of the fact that turning mixed into a non-nullable type turns it into a union.

Some restrictions/enforcements are already there: you are not allowed to add a union to a union, or an intersection to another intersection, which is completely arbitrary if you consider that these could be merged later. But in fact I believe that @mtarld's is the right approach here and am just trying to build on top of it:

IMHO, object graphs should be as canonical as possible. For instance, the fact that unions sort their subtypes is brilliant and without it, to compare Type::union(Type::int(), Type::string()) to Type::union(Type::string(), Type::int()) would be near impossible.

To make this possible requires enforcing the normal form in the Type classes afaik. This is actually partly implemented but incomplete, which is what I tried to improve in my own PR.

In return, TypeFactoryTrait methods can be used to add some flexibility to types creation and ensure that Type::union(Type::int(), Type::union(Type::string(), Type::bool())) is not only allowed but will create the exact same type as Type::union(Type::bool(), Type::int(), Type::string()).

If we consider that we should allow any random combination of types, then the current checks should be removed (but I would consider that a step back).

TL;DR (sorry but I really like this component, I talk too much sometimes): I'd like to be able to assume that echo $type will always give me a syntactically valid PHP type. We can already use Type::union() and Type::intersection() to combine composite types in a more flexible way and could add more if necessary.

@mtarld mtarld force-pushed the feat/type-specification branch from d1d2368 to cc38917 Compare August 14, 2024 07:29
@mtarld
Copy link
Contributor Author

mtarld commented Aug 14, 2024

Ok, I ended up with the following:

  • I renamed Type::is to Type::satisfy and Type::isA to Type::isIdentifiedBy so that it is easier to understand
  • I added the CompositeTypeInterface::composedTypesSatisfy and WrappingTypeInterface::wrappedTypeSatisfy methods so that it is easier to define whether we want to check the root, the children or the leaves (you can have a look for an example in Type::isIdentifiedBy

I'm still not sure if the Type::asNonNullable is needed, as we now have that NullableType. I mean, does it make sense to make mixed or null as non-nullable? To me, the main aim for that method me is to recover int in int|null, which is covered by NullableType::getWrappedType.

Plus, I disallowed the creation of an UnionType with null as part, and enforced the creation of a NullableType instead (see UnionType::__construct). But I'm not sure if that's a good idea.

WDYT?

@mtarld mtarld force-pushed the feat/type-specification branch from cc38917 to 5401c2c Compare August 14, 2024 14:25
@mtarld
Copy link
Contributor Author

mtarld commented Aug 14, 2024

Moreover, I pulled some ideas from the great work of @skalpa in #57943 that make types more consistent and make my work on nullable types more easy and relevant.

@mtarld mtarld force-pushed the feat/type-specification branch from 5401c2c to 61e49e7 Compare August 14, 2024 14:29
Copy link
Member

@chalasr chalasr left a comment

Choose a reason for hiding this comment

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

Better API

@fabpot
Copy link
Member

fabpot commented Oct 18, 2024

It looks like the tests are still failing.

@mtarld
Copy link
Contributor Author

mtarld commented Oct 18, 2024

The high-deps build is failing because this PR purposely contains BC breaks as the component is experimental in 7.2. Failures will go away once this is merged.

@chalasr
Copy link
Member

chalasr commented Oct 18, 2024

Indeed, tests will pass once merged. Then dependant components' tests will start failing on 7.1 as they will install type-info 7.2 so we will have to forbid it by adding conflict rules.

@xabbuh
Copy link
Member

xabbuh commented Oct 18, 2024

The issue is that the conflict will lead to the fact that Composer may not install the latest 7.1 version of said component because of the conflict when 7.2 of the TypeInfo component is installed as we already have releases claiming to be compatible with TypeInfo 7.2. So the damage is already done. The only solution I see is to make the PropertyInfo component compatible with TypeInfo 7.2 as a bugfix in 7.1.

In the future we need to be careful when adding requirements for experimental components and do not allow to install new minor versions of the from the beginning.

@stof
Copy link
Member

stof commented Oct 18, 2024

@xabbuh a way to avoid that breakage is to make type-info conflict with older versions of those components, to workaround the fact that we used semver constraints for an experimental package not guaranteeing semver yet.

@chalasr
Copy link
Member

chalasr commented Oct 18, 2024

Sounds good

@xabbuh
Copy link
Member

xabbuh commented Oct 18, 2024

Indeed, @stof's suggestion is a good idea that we can use in this PR.

@mtarld mtarld force-pushed the feat/type-specification branch from da9c58b to d8db247 Compare October 18, 2024 12:47
@mtarld
Copy link
Contributor Author

mtarld commented Oct 18, 2024

Yes, that a great idea, I just pushed that fix.

@mtarld mtarld force-pushed the feat/type-specification branch 2 times, most recently from 3089f2d to 75fce6f Compare October 22, 2024 12:54
@mtarld mtarld force-pushed the feat/type-specification branch from 75fce6f to 8c376c8 Compare October 22, 2024 13:04
@chalasr
Copy link
Member

chalasr commented Oct 22, 2024

@symfony/mergers Additional review needed to move forward before the component is considered stable.

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

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

Let's give this a try :)

@nicolas-grekas
Copy link
Member

Thank you @mtarld.

@nicolas-grekas nicolas-grekas merged commit 4413b2a into symfony:7.2 Nov 13, 2024
9 of 10 checks passed
@fabpot fabpot mentioned this pull request Nov 13, 2024
xabbuh added a commit that referenced this pull request Nov 14, 2024
This PR was merged into the 7.2 branch.

Discussion
----------

[TypeInfo] tighten conflict rules

| Q             | A
| ------------- | ---
| Branch?       | 7.2
| Bug fix?      | no
| New feature?  | no
| Deprecations? | no
| Issues        | Fix #57630 (comment)
| License       | MIT

Commits
-------

87579c0 tighten conflict rules
nicolas-grekas added a commit that referenced this pull request Nov 14, 2024
…bility (mtarld)

This PR was merged into the 7.1 branch.

Discussion
----------

[PropertyInfo][Serializer][Validator] TypeInfo 7.2 compatibility

| Q             | A
| ------------- | ---
| Branch?       | 7.1
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Issues        |
| License       | MIT

Make Serializer, PropertyInfo, and Validator components compatible with TypeInfo 7.2 breaking changes (#57630) so that we can remove the conflicts in TypeInfo's `composer.json` file in 7.2

Commits
-------

45d3ad2 [Serializer][PropertyInfo][Validator] TypeInfo 7.2 compatibility
@mtarld mtarld deleted the feat/type-specification branch November 15, 2024 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature ❄️ Feature Freeze Important Pull Requests to finish before the next Symfony "feature freeze" Status: Reviewed TypeInfo
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants