Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.
Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.
- RFC: https://wiki.php.net/rfc/bound_erased_generic_types
- Implementation: https://github.com/php/php-src/pull/21969
Thanks,
Seifeddine.
Hey Seifeddine,
Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.
- RFC:https://wiki.php.net/rfc/bound_erased_generic_types
- Implementation:https://github.com/php/php-src/pull/21969
Thanks,
Seifeddine.
I have a bunch of questions and feedback:
The requirement of ordering seems unnecessary to me - why would we not
want to be able to write <T: Box<U>, U: Box<T>>. Alternatingly recursive
types are not unheard of. Seems like an arbitrary restriction; and for
compilation purposes it only requires collecting all parameter names
before evaluating them.
Your tests also show restrictions around intersection types, e.g. "Type
parameter T with bound mixed cannot be part of an intersection type" for
'class Foo {} function x<T>(): T & Foo {}'. What's the motivation behind
it? This looks fairly natural to me: x() promises to return an instance
of Foo which also fulfills the bound T. Any child class of Foo which
happens to implement T will fulfill that contract.
I would like to plead to skip the arity validation, except for "more
parameters than allowed":
- This inhibits graceful addition of generics - any library adding them
requires callers to immediately update all caller sites.
- It would also make addition of generics to Iterator classes etc.
completely uncontroversial. - This would be more in line with PHP's general "no type is effectively
the highest possible bound" approach. I.e. "class A extends Box" and
"class A extends Box<mixed>" would be equivalent. - This would also allow for future incremental runtime generics: you'd
start with <never> and as you call stuff with values, the type becomes
broader.
This is the one thing which makes the whole RFC a non-starter for me if
required:
Typing is optional in PHP!
Your tests show that this specific example is allowed, which strikes me
as odd. Why would we not check the arity here?
class Container {}
function f(Container<int> $x): Container<string> { return $x; }
Diamond checks:
Are these necessarily problematic? if you inherit Box<int> and
Box<string>, it simply means that the generic parameter, when placed in
a contravariant location will accept int|string, when placed into return
or property types it'll evaluate to never.
If you disagree (that's possibly fine), a diamond covariant parameter
should be allowed in any case though, i.e. if Box<+T>, then an interface
shall be able to implement Box<string>, Box<int>. At least at a glance I
don't find such a test - if it already works, nice, then please just add
the test!
Is class ABox implements Box<self> allowed, or do we need to write
implements Box<ABox>?
I'm also not sold on the turbofish syntax. I hate it in Rust, which I
have to write nearly daily. I forget these :: SO often. And then the
Linter yells at me and I correct it.
I understand that there are language limitations, in particular with the
array syntax, but honestly, I'd rather just have the parser shift in
favor of the existing syntax - for these rare conflicting cases forcing
parenthesis around the generic would be nicer, i.e. [A<B, B>(C)] would
continue carrying the meaning it has today, and we'd require writing
[(A<B, B>(C))] for that case.
I'm not quite sure if + and - are the proper choices. I'm more used to
C# myself with in and out being more obvious to me. I also admit that I
initially assumed "+" to be covariant - the sum of stuff accepted, and
"-" contravariant, subtracting what can be returned. But this particular
bikesheds color is not too important to me.
Otherwise, it's a pretty solid RFC which should be extensible with
runtime generics eventually. (In particular runtime generics on the
class inheritance level should be a no-brainer to add with the existing
syntax.)
Thanks,
Bob
Hi Bob,
Hey Seifeddine,
Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.
- RFC: https://wiki.php.net/rfc/bound_erased_generic_types
- Implementation: https://github.com/php/php-src/pull/21969
Thanks,
Seifeddine.I have a bunch of questions and feedback:
The requirement of ordering seems unnecessary to me - why would we not want to be able to write <T: Box<U>, U: Box<T>>. Alternatingly recursive types are not unheard of. Seems like an arbitrary restriction; and for compilation purposes it only requires collecting all parameter names before evaluating them.
Your tests also show restrictions around intersection types, e.g. "Type parameter T with bound mixed cannot be part of an intersection type" for 'class Foo {} function x<T>(): T & Foo {}'. What's the motivation behind it? This looks fairly natural to me: x() promises to return an instance of Foo which also fulfills the bound T. Any child class of Foo which happens to implement T will fulfill that contract.
I would like to plead to skip the arity validation, except for "more parameters than allowed":
- This inhibits graceful addition of generics - any library adding them requires callers to immediately update all caller sites.
- It would also make addition of generics to Iterator classes etc. completely uncontroversial.
- This would be more in line with PHP's general "no type is effectively the highest possible bound" approach. I.e. "class A extends Box" and "class A extends Box<mixed>" would be equivalent.
- This would also allow for future incremental runtime generics: you'd start with <never> and as you call stuff with values, the type becomes broader.
This is the one thing which makes the whole RFC a non-starter for me if required:
Typing is optional in PHP!
Your tests show that this specific example is allowed, which strikes me as odd. Why would we not check the arity here?
class Container {}
function f(Container<int> $x): Container<string> { return $x; }Diamond checks:
Are these necessarily problematic? if you inherit Box<int> and Box<string>, it simply means that the generic parameter, when placed in a contravariant location will accept int|string, when placed into return or property types it'll evaluate to never.
If you disagree (that's possibly fine), a diamond covariant parameter should be allowed in any case though, i.e. if Box<+T>, then an interface shall be able to implement Box<string>, Box<int>. At least at a glance I don't find such a test - if it already works, nice, then please just add the test!
Is class ABox implements Box<self> allowed, or do we need to write implements Box<ABox>?
I'm also not sold on the turbofish syntax. I hate it in Rust, which I have to write nearly daily. I forget these :: SO often. And then the Linter yells at me and I correct it.
I understand that there are language limitations, in particular with the array syntax, but honestly, I'd rather just have the parser shift in favor of the existing syntax - for these rare conflicting cases forcing parenthesis around the generic would be nicer, i.e.[A<B, B>(C)]would continue carrying the meaning it has today, and we'd require writing[(A<B, B>(C))]for that case.I'm not quite sure if + and - are the proper choices. I'm more used to C# myself with in and out being more obvious to me. I also admit that I initially assumed "+" to be covariant - the sum of stuff accepted, and "-" contravariant, subtracting what can be returned. But this particular bikesheds color is not too important to me.
Otherwise, it's a pretty solid RFC which should be extensible with runtime generics eventually. (In particular runtime generics on the class inheritance level should be a no-brainer to add with the existing syntax.)
Thanks,
Bob
Thanks for the careful read. Going point by point.
- Ordering of type parameter declarations
The restriction is implementation-level, not fundamental. We register
parameter names before we compile bounds, so allowing <T: Box<U>, U:
Box<T>> is a "small" change. I left it out for the initial cut because
I didn't want to bake mutually-recursive bounds into the spec without
seeing whether anyone actually wants them in practice. If others agree
this is worth having, I'm happy to drop the restriction before vote.
- Type parameters in intersection types
The check rejects an intersection where one side is a type parameter
whose bound is mixed, because the erased form can be anything,
including a scalar. Scalars don't intersect with anything, today. (
ref https://3v4l.org/mdvFA#v )
The error message in the test you saw is precisely about the unbounded
case. If T is bound to an object-shaped type (T: object, T: SomeInterface, T: SomeClass, ...), then T & Foo is allowed. the
erased form is guaranteed to be a legal intersection operand. So this
is the same rule PHP already enforces today, just applied through the
erased form.
- Arity validation at consumer call sites
I think this one is a misunderstanding. Arity validation only fires
when the caller writes turbofish. Without turbofish, nothing changes
at the call site:
function id<T>(T $v): T { return $v; }
id($x); // no validation, no behavior change
id::<int>($x); // arity + bound checked
So a library can add generic parameters to its public surface and
every existing caller (none of which uses turbofish, because turbofish
doesn't exist today) keeps working unchanged. The validation is opt-in
at the use site. Same for new and method calls.
This is exactly the graceful-addition story you're asking for. The
existing tests demonstrate it.
- Generic args on a non-generic class in a signature
class Container {}
function f(Container<int> $x): Container<string> { return $x; }
This is accepted, and on purpose. PHP doesn't load classes from
signatures, they load on use: https://3v4l.org/DnIKQ#v
To validate arity at compile time, we'd have to load Container,
which is a behavioral and performance regression. The cost of being
strict here is much higher than the cost of being permissive. The same
logic that already lets you reference an unloaded class in a signature
lets you reference an unloaded class with type arguments in a
signature. Validation happens once the class actually gets resolved at
a use site (new, turbofish call, etc.).
- Diamond inheritance
The diamond check is necessary because methods get substituted with
the type arguments at link time. Consider:
interface Box<T> { public function set(T $v): void; }
class C implements Box<int>, Box<string> {}
After substitution, C must implement both set(int): void and
set(string): void. PHP has no way to represent two methods with the
same name and different signatures ( i.e overloading ), one of them
has to win, and either choice silently breaks one of the parent
contracts. Same problem in contravariant position. The check rejects
this at link time rather than letting it produce a class that violates
its own interface.
For purely covariant slots you have a point, get(): int and get(): string could in principle be reconciled to get(): int|string (an
LUB). The current implementation rejects all diamonds uniformly to
keep linking deterministic and to avoid synthesizing union types
during inheritance. Relaxing it for the covariant case is a reasonable
follow-up, not something I want to bake in before vote.
-
class ABox implements Box<self>
It is allowed and works as you'd expect. self resolves to the
implementing class.
interface Box<+T> { public function get(): T; }
class ABox implements Box<self> {
public function get(): self { return $this; }
}
var_dump((new ABox)->get() instanceof ABox); // true
- Turbofish
We have to disagree here. Turbofish:
- has zero parser conflict with comparison operators in expression position
- is uniform across
new, function calls, method calls, FCCs, attributes - requires no context-sensitive disambiguation rule
The alternative adds a rule a developer has to learn and apply at
exactly the worst places (inside attributes, array expressions,
ternaries). I'd rather pay the :: tax than introduce a
context-sensitive parser rule that bites people inside attributes
specifically. Rust's choice was a forced one because of <> overload,
and it's the right one for PHP too for the same reason.
-
- / - markers
Picked because they don't require any new reserved words. in/out
reads well but I'm not comfortable burning two keywords for a feature
where two pieces of punctuation already do the job.
On the "+ = sum of accepted" intuition: the convention here is the
standard one from variance literature. + marks positions where the
type can be widened (covariant, e.g., returns), - marks positions
where it can be narrowed (contravariant, e.g., parameters). It also
matches Hack, Scala, and Kotlin, so there is prior art the ecosystem
already maps to.
- Runtime generics
Agreed entirely. The design is bound-erased for now. Nothing in it
precludes a follow-up RFC adding reified generics at the inheritance
level. That slice is the most useful and the cleanest to bolt on, the
engine's type-parameter representation is already structured to
support it.
Cheers,
Seifeddine.
Thanks for the quick reply!
Let me respond inline to avoid backtracking too much.
I have a bunch of questions and feedback:
The requirement of ordering seems unnecessary to me - why would we not want to be able to write <T: Box<U>, U: Box<T>>. Alternatingly recursive types are not unheard of. Seems like an arbitrary restriction; and for compilation purposes it only requires collecting all parameter names before evaluating them.
Your tests also show restrictions around intersection types, e.g. "Type parameter T with bound mixed cannot be part of an intersection type" for 'class Foo {} function x<T>(): T & Foo {}'. What's the motivation behind it? This looks fairly natural to me: x() promises to return an instance of Foo which also fulfills the bound T. Any child class of Foo which happens to implement T will fulfill that contract.
I would like to plead to skip the arity validation, except for "more parameters than allowed":
- This inhibits graceful addition of generics - any library adding them requires callers to immediately update all caller sites.
- It would also make addition of generics to Iterator classes etc. completely uncontroversial.
- This would be more in line with PHP's general "no type is effectively the highest possible bound" approach. I.e. "class A extends Box" and "class A extends Box<mixed>" would be equivalent.
- This would also allow for future incremental runtime generics: you'd start with <never> and as you call stuff with values, the type becomes broader.
This is the one thing which makes the whole RFC a non-starter for me if required:
Typing is optional in PHP!
Your tests show that this specific example is allowed, which strikes me as odd. Why would we not check the arity here?
class Container {}
function f(Container<int> $x): Container<string> { return $x; }Diamond checks:
Are these necessarily problematic? if you inherit Box<int> and Box<string>, it simply means that the generic parameter, when placed in a contravariant location will accept int|string, when placed into return or property types it'll evaluate to never.
If you disagree (that's possibly fine), a diamond covariant parameter should be allowed in any case though, i.e. if Box<+T>, then an interface shall be able to implement Box<string>, Box<int>. At least at a glance I don't find such a test - if it already works, nice, then please just add the test!
Is class ABox implements Box<self> allowed, or do we need to write implements Box<ABox>?
I'm also not sold on the turbofish syntax. I hate it in Rust, which I have to write nearly daily. I forget these :: SO often. And then the Linter yells at me and I correct it.
I understand that there are language limitations, in particular with the array syntax, but honestly, I'd rather just have the parser shift in favor of the existing syntax - for these rare conflicting cases forcing parenthesis around the generic would be nicer, i.e.[A<B, B>(C)]would continue carrying the meaning it has today, and we'd require writing[(A<B, B>(C))]for that case.I'm not quite sure if + and - are the proper choices. I'm more used to C# myself with in and out being more obvious to me. I also admit that I initially assumed "+" to be covariant - the sum of stuff accepted, and "-" contravariant, subtracting what can be returned. But this particular bikesheds color is not too important to me.
Otherwise, it's a pretty solid RFC which should be extensible with runtime generics eventually. (In particular runtime generics on the class inheritance level should be a no-brainer to add with the existing syntax.)
Thanks,
BobThanks for the careful read. Going point by point.
- Ordering of type parameter declarations
The restriction is implementation-level, not fundamental. We register
parameter names before we compile bounds, so allowing <T: Box<U>, U:
Box<T>> is a "small" change. I left it out for the initial cut because
I didn't want to bake mutually-recursive bounds into the spec without
seeing whether anyone actually wants them in practice. If others agree
this is worth having, I'm happy to drop the restriction before vote.
Yes, that's the impression I had - an arbitrary restriction to make it a
bit simpler at compile time.
I'd suggest just dropping it, why have it, actually? It should be a
relatively easy change. I don't see any concrete advantage of this,
apart from the minor simplification this restriction would have in compiler.
- Type parameters in intersection types
The check rejects an intersection where one side is a type parameter
whose bound ismixed, because the erased form can be anything,
including a scalar. Scalars don't intersect with anything, today. (
refhttps://3v4l.org/mdvFA#v )The error message in the test you saw is precisely about the unbounded
case. IfTis bound to an object-shaped type (T: object,T: SomeInterface,T: SomeClass, ...), thenT & Foois allowed. the
erased form is guaranteed to be a legal intersection operand. So this
is the same rule PHP already enforces today, just applied through the
erased form.
Ah, I see, it needs a T: object. (or named class). It's not quite
obvious from the error message, so I'd suggest adding a suggestion for
"at least T: object or a stronger bound" then.
That makes some sense. The question would be if never types should be
possible to reached, but this I've basically asked already when asking
about diamond checks.
- Arity validation at consumer call sites
I think this one is a misunderstanding. Arity validation only fires
when the caller writes turbofish. Without turbofish, nothing changes
at the call site:function id<T>(T $v): T { return $v; } id($x); // no validation, no behavior change id::<int>($x); // arity + bound checkedSo a library can add generic parameters to its public surface and
every existing caller (none of which uses turbofish, because turbofish
doesn't exist today) keeps working unchanged. The validation is opt-in
at the use site. Same fornewand method calls.This is exactly the graceful-addition story you're asking for. The
existing tests demonstrate it.
This is not quite obvious from the RFC. I'd recommend adding a
subsection to "What is enforced where" detailing that these are not
checked: I thought "turbofish arity" would apply to everywhere, not just
explicitly where the ::<> syntax is actually used.
Are they also not checked for inheritance? Or just for caller sites?
Sorry for missing it in tests, you have a LOT of tests!
- Generic args on a non-generic class in a signature
class Container {} function f(Container<int> $x): Container<string> { return $x; }This is accepted, and on purpose. PHP doesn't load classes from
signatures, they load on use:https://3v4l.org/DnIKQ#vTo validate arity at compile time, we'd have to load
Container,
which is a behavioral and performance regression. The cost of being
strict here is much higher than the cost of being permissive. The same
logic that already lets you reference an unloaded class in a signature
lets you reference an unloaded class with type arguments in a
signature. Validation happens once the class actually gets resolved at
a use site (new, turbofish call, etc.).
I'm actually suggesting validation at runtime here, i.e. once the class
type check passes, to check whether the arity is matching for the class
of the argument.
I'm certainly not asking for compile time checks here. But leaving this
unchecked sort-of makes it the odd-one out here.
- Diamond inheritance
The diamond check is necessary because methods get substituted with
the type arguments at link time. Consider:interface Box<T> { public function set(T $v): void; } class C implements Box<int>, Box<string> {}After substitution, C must implement both
set(int): voidand
set(string): void. PHP has no way to represent two methods with the
same name and different signatures ( i.e overloading ), one of them
has to win, and either choice silently breaks one of the parent
contracts. Same problem in contravariant position. The check rejects
this at link time rather than letting it produce a class that violates
its own interface.For purely covariant slots you have a point,
get(): intandget(): stringcould in principle be reconciled toget(): int|string(an
LUB). The current implementation rejects all diamonds uniformly to
keep linking deterministic and to avoid synthesizing union types
during inheritance. Relaxing it for the covariant case is a reasonable
follow-up, not something I want to bake in before vote.
You got it the wrong way round, the union needs to be allowed on the
parameters, not the return type.
set(string): void and set(int): void can be merged into set(string|int):
void.
I'd also like to mention here that:
interface A { public function set(int $v): void; }
interface B { public function set(string $v): void; }
class C implements A, B { public function set(int|string $v): void {} }
is perfectly valid today. Not allowing this for the contravariant case
would make it inconsistent with what's currently supported in PHP.
This needs no overloading at all.
class ABox implements Box<self>It is allowed and works as you'd expect.
selfresolves to the
implementing class.interface Box<+T> { public function get(): T; } class ABox implements Box<self> { public function get(): self { return $this; } } var_dump((new ABox)->get() instanceof ABox); // true
Nice!
- Turbofish
We have to disagree here. Turbofish:
- has zero parser conflict with comparison operators in expression position
- is uniform across
new, function calls, method calls, FCCs, attributes- requires no context-sensitive disambiguation rule
The alternative adds a rule a developer has to learn and apply at
exactly the worst places (inside attributes, array expressions,
ternaries). I'd rather pay the::tax than introduce a
context-sensitive parser rule that bites people inside attributes
specifically. Rust's choice was a forced one because of<>overload,
and it's the right one for PHP too for the same reason.
Alright, let's disagree here.
- / - markers
Picked because they don't require any new reserved words.
in/out
reads well but I'm not comfortable burning two keywords for a feature
where two pieces of punctuation already do the job.On the "+ = sum of accepted" intuition: the convention here is the
standard one from variance literature.+marks positions where the
type can be widened (covariant, e.g., returns),-marks positions
where it can be narrowed (contravariant, e.g., parameters). It also
matches Hack, Scala, and Kotlin, so there is prior art the ecosystem
already maps to.
I understand, I've never used any of those languages for more than
targeted edits, so I didn't know.
I guess it's fine to not diverge here.
By the way, you don't necessarily need a new keyword, in fact you could
just allow two consecutive T_STRING at that position and emit a parser
error when the first one is neither of "in" or "out".
Thanks,
Bob
Hi Bob,
Thanks for the quick reply!
Let me respond inline to avoid backtracking too much.
I have a bunch of questions and feedback:
The requirement of ordering seems unnecessary to me - why would we not want to be able to write <T: Box<U>, U: Box<T>>. Alternatingly recursive types are not unheard of. Seems like an arbitrary restriction; and for compilation purposes it only requires collecting all parameter names before evaluating them.
Your tests also show restrictions around intersection types, e.g. "Type parameter T with bound mixed cannot be part of an intersection type" for 'class Foo {} function x<T>(): T & Foo {}'. What's the motivation behind it? This looks fairly natural to me: x() promises to return an instance of Foo which also fulfills the bound T. Any child class of Foo which happens to implement T will fulfill that contract.
I would like to plead to skip the arity validation, except for "more parameters than allowed":
- This inhibits graceful addition of generics - any library adding them requires callers to immediately update all caller sites.
- It would also make addition of generics to Iterator classes etc. completely uncontroversial.
- This would be more in line with PHP's general "no type is effectively the highest possible bound" approach. I.e. "class A extends Box" and "class A extends Box<mixed>" would be equivalent.
- This would also allow for future incremental runtime generics: you'd start with <never> and as you call stuff with values, the type becomes broader.
This is the one thing which makes the whole RFC a non-starter for me if required:
Typing is optional in PHP!
Your tests show that this specific example is allowed, which strikes me as odd. Why would we not check the arity here?
class Container {}
function f(Container<int> $x): Container<string> { return $x; }Diamond checks:
Are these necessarily problematic? if you inherit Box<int> and Box<string>, it simply means that the generic parameter, when placed in a contravariant location will accept int|string, when placed into return or property types it'll evaluate to never.
If you disagree (that's possibly fine), a diamond covariant parameter should be allowed in any case though, i.e. if Box<+T>, then an interface shall be able to implement Box<string>, Box<int>. At least at a glance I don't find such a test - if it already works, nice, then please just add the test!
Is class ABox implements Box<self> allowed, or do we need to write implements Box<ABox>?
I'm also not sold on the turbofish syntax. I hate it in Rust, which I have to write nearly daily. I forget these :: SO often. And then the Linter yells at me and I correct it.
I understand that there are language limitations, in particular with the array syntax, but honestly, I'd rather just have the parser shift in favor of the existing syntax - for these rare conflicting cases forcing parenthesis around the generic would be nicer, i.e.[A<B, B>(C)]would continue carrying the meaning it has today, and we'd require writing[(A<B, B>(C))]for that case.I'm not quite sure if + and - are the proper choices. I'm more used to C# myself with in and out being more obvious to me. I also admit that I initially assumed "+" to be covariant - the sum of stuff accepted, and "-" contravariant, subtracting what can be returned. But this particular bikesheds color is not too important to me.
Otherwise, it's a pretty solid RFC which should be extensible with runtime generics eventually. (In particular runtime generics on the class inheritance level should be a no-brainer to add with the existing syntax.)
Thanks,
BobThanks for the careful read. Going point by point.
- Ordering of type parameter declarations
The restriction is implementation-level, not fundamental. We register
parameter names before we compile bounds, so allowing <T: Box<U>, U:
Box<T>> is a "small" change. I left it out for the initial cut because
I didn't want to bake mutually-recursive bounds into the spec without
seeing whether anyone actually wants them in practice. If others agree
this is worth having, I'm happy to drop the restriction before vote.Yes, that's the impression I had - an arbitrary restriction to make it a bit simpler at compile time.
I'd suggest just dropping it, why have it, actually? It should be a relatively easy change. I don't see any concrete advantage of this, apart from the minor simplification this restriction would have in compiler.
- Type parameters in intersection types
The check rejects an intersection where one side is a type parameter
whose bound ismixed, because the erased form can be anything,
including a scalar. Scalars don't intersect with anything, today. (
ref https://3v4l.org/mdvFA#v )The error message in the test you saw is precisely about the unbounded
case. IfTis bound to an object-shaped type (T: object,T: SomeInterface,T: SomeClass, ...), thenT & Foois allowed. the
erased form is guaranteed to be a legal intersection operand. So this
is the same rule PHP already enforces today, just applied through the
erased form.Ah, I see, it needs a T: object. (or named class). It's not quite obvious from the error message, so I'd suggest adding a suggestion for "at least T: object or a stronger bound" then.
That makes some sense. The question would be if never types should be possible to reached, but this I've basically asked already when asking about diamond checks.
- Arity validation at consumer call sites
I think this one is a misunderstanding. Arity validation only fires
when the caller writes turbofish. Without turbofish, nothing changes
at the call site:function id<T>(T $v): T { return $v; } id($x); // no validation, no behavior change id::<int>($x); // arity + bound checkedSo a library can add generic parameters to its public surface and
every existing caller (none of which uses turbofish, because turbofish
doesn't exist today) keeps working unchanged. The validation is opt-in
at the use site. Same fornewand method calls.This is exactly the graceful-addition story you're asking for. The
existing tests demonstrate it.This is not quite obvious from the RFC. I'd recommend adding a subsection to "What is enforced where" detailing that these are not checked: I thought "turbofish arity" would apply to everywhere, not just explicitly where the ::<> syntax is actually used.
Are they also not checked for inheritance? Or just for caller sites?
Sorry for missing it in tests, you have a LOT of tests!
- Generic args on a non-generic class in a signature
class Container {} function f(Container<int> $x): Container<string> { return $x; }This is accepted, and on purpose. PHP doesn't load classes from
signatures, they load on use: https://3v4l.org/DnIKQ#vTo validate arity at compile time, we'd have to load
Container,
which is a behavioral and performance regression. The cost of being
strict here is much higher than the cost of being permissive. The same
logic that already lets you reference an unloaded class in a signature
lets you reference an unloaded class with type arguments in a
signature. Validation happens once the class actually gets resolved at
a use site (new, turbofish call, etc.).I'm actually suggesting validation at runtime here, i.e. once the class type check passes, to check whether the arity is matching for the class of the argument.
I'm certainly not asking for compile time checks here. But leaving this unchecked sort-of makes it the odd-one out here.
- Diamond inheritance
The diamond check is necessary because methods get substituted with
the type arguments at link time. Consider:interface Box<T> { public function set(T $v): void; } class C implements Box<int>, Box<string> {}After substitution, C must implement both
set(int): voidand
set(string): void. PHP has no way to represent two methods with the
same name and different signatures ( i.e overloading ), one of them
has to win, and either choice silently breaks one of the parent
contracts. Same problem in contravariant position. The check rejects
this at link time rather than letting it produce a class that violates
its own interface.For purely covariant slots you have a point,
get(): intandget(): stringcould in principle be reconciled toget(): int|string(an
LUB). The current implementation rejects all diamonds uniformly to
keep linking deterministic and to avoid synthesizing union types
during inheritance. Relaxing it for the covariant case is a reasonable
follow-up, not something I want to bake in before vote.You got it the wrong way round, the union needs to be allowed on the parameters, not the return type.
set(string): void and set(int): void can be merged into set(string|int): void.
I'd also like to mention here that:
interface A { public function set(int $v): void; }
interface B { public function set(string $v): void; }
class C implements A, B { public function set(int|string $v): void {} }is perfectly valid today. Not allowing this for the contravariant case would make it inconsistent with what's currently supported in PHP.
This needs no overloading at all.
class ABox implements Box<self>It is allowed and works as you'd expect.
selfresolves to the
implementing class.interface Box<+T> { public function get(): T; } class ABox implements Box<self> { public function get(): self { return $this; } } var_dump((new ABox)->get() instanceof ABox); // trueNice!
- Turbofish
We have to disagree here. Turbofish:
- has zero parser conflict with comparison operators in expression position
- is uniform across
new, function calls, method calls, FCCs, attributes- requires no context-sensitive disambiguation rule
The alternative adds a rule a developer has to learn and apply at
exactly the worst places (inside attributes, array expressions,
ternaries). I'd rather pay the::tax than introduce a
context-sensitive parser rule that bites people inside attributes
specifically. Rust's choice was a forced one because of<>overload,
and it's the right one for PHP too for the same reason.Alright, let's disagree here.
- / - markers
Picked because they don't require any new reserved words.
in/out
reads well but I'm not comfortable burning two keywords for a feature
where two pieces of punctuation already do the job.On the "+ = sum of accepted" intuition: the convention here is the
standard one from variance literature.+marks positions where the
type can be widened (covariant, e.g., returns),-marks positions
where it can be narrowed (contravariant, e.g., parameters). It also
matches Hack, Scala, and Kotlin, so there is prior art the ecosystem
already maps to.I understand, I've never used any of those languages for more than targeted edits, so I didn't know.
I guess it's fine to not diverge here.
By the way, you don't necessarily need a new keyword, in fact you could just allow two consecutive
T_STRINGat that position and emit a parser error when the first one is neither of "in" or "out".Thanks,
Bob
Thanks for the reply!
- Ordering of type parameter declarations
Agreed, dropped. Forward references and mutually recursive bounds
within a single parameter list are now allowed:
function f<U : T, T>(U $x): T { /* ... */ } // forward
class Pair<T : Box<U>, U : Box<T>> {} // mutual
Defaults still require backward-only references, meaning omitted
arguments resolve in one pass at instantiation. Direct self-reference
at the head of a bound (<T : T>) is still rejected; the indirect
form (<T : Box<T>>) is still allowed.
- Intersection error message
Improved the diagnostic to point directly at the fix instead of just
stating the rule. The message now reads:
Type parameter T with bound mixed cannot be part of an intersection
type; use an object-shaped bound (e.g. T: object)
Diff:
Thanks for the reply!
- Ordering of type parameter declarations
Agreed, dropped. Forward references and mutually recursive bounds
within a single parameter list are now allowed:
function f<U : T, T>(U $x): T { /* ... */ } // forward
class Pair<T : Box<U>, U : Box<T>> {} // mutual
Defaults still require backward-only references, meaning omitted
arguments resolve in one pass at instantiation. Direct self-reference
at the head of a bound (<T : T>) is still rejected; the indirect
form (<T : Box<T>>) is still allowed.
- Intersection error message
Improved the diagnostic to point directly at the fix instead of just
stating the rule. The message now reads:
Type parameter T with bound mixed cannot be part of an intersection
type; use an object-shaped bound (e.g. T: object)
- Arity validation at consumer call sites
You're right that the RFC didn't say this clearly. Added a "What is
not checked" subsection under "What is enforced where" that lists the
exact sites where the engine intentionally omits arity or bounds
validation. The whole point of opt-in is the graceful-addition story
you're after; the RFC now spells that out.
- Runtime arity check at call boundaries
In principle, sure. Once the runtime confirms the value matches the
class, we can also validate the signature's type arguments against the
class's actual declared arity and bounds. So in:
class C {}
function foo(C<int, string> $x): void {}
foo(new C());
we'd error because C has no generic parameters but the signature
supplied two type arguments.
The catch is that this isn't only about parameters. It applies to
every place the engine resolves a class-typed type expression at
runtime.
So a bit more complicated, not a small change. I want to spend more
time on it before committing to text: what exactly gets validated,
where the result gets cached so we aren't paying for it on every typed
call, how it interacts with the substitution chain at link time, and
what the hot-path cost actually is on a profiled workload. I'd like to
see what others on the list think too, since the call is a trade-off
between strictness and performance and people will weigh those
differently.
If after that the answer is "yes, fold it in", I'll fold it in. But I
don't want to promise it inside this RFC until I've done the
investigation.
- Diamond inheritance - I had the direction wrong
Yea, sorry. Contravariant (parameter) positions are the ones that
merge cleanly into a union, not return positions. Your example is
right.
For the generic case, the contravariant side is the easy one:
interface Box<-T> { public function set(T $v): void; }
class C implements Box<int>, Box<string> { /* set(int|string) */ }
The implementer's substituted prototype is the union of the two
contravariant slots.
The covariant side is more nuanced. get(): int and get(): string
merged would have to return both ( i.e. int & string ), and PHP
rejects intersections involving scalars (because impossible!). So a
covariant diamond with scalar bindings is unrepresentable. It only
becomes representable when the type parameter is bounded by an
object-shaped type, in which case the implementer's return type
collapses cleanly to an intersection:
interface Box<+T : object> { public function get(): T; }
interface A {}
interface B {}
class C implements Box<A>, Box<B> {
public function get(): A&B { /* ... */ }
}
Here A & B is a valid PHP intersection, so the merge is sound.
I'll look into this. I think we can fit it into this RFC, but I want
to investigate the implementation first. I keep the RFC and the
implementation in sync and don't want to commit to text that isn't
backed by working code yet.
Thanks,
Seifeddine.
- Arity validation at consumer call sites
You're right that the RFC didn't say this clearly. Added a "What is
not checked" subsection under "What is enforced where" that lists the
exact sites where the engine intentionally omits arity or bounds
validation. The whole point of opt-in is the graceful-addition story
you're after; the RFC now spells that out.
- Runtime arity check at call boundaries
In principle, sure. Once the runtime confirms the value matches the
class, we can also validate the signature's type arguments against the
class's actual declared arity and bounds. So in:
class C {}
function foo(C<int, string> $x): void {}
foo(new C());
we'd error because C has no generic parameters but the signature
supplied two type arguments.
The catch is that this isn't only about parameters. It applies to
every place the engine resolves a class-typed type expression at
runtime.
So a bit more complicated, not a small change. I want to spend more
time on it before committing to text: what exactly gets validated,
where the result gets cached so we aren't paying for it on every typed
call, how it interacts with the substitution chain at link time, and
what the hot-path cost actually is on a profiled workload. I'd like to
see what others on the list think too, since the call is a trade-off
between strictness and performance and people will weigh those
differently.
If the answer after that is "yes, add it,", I'll fold it in. But I
don't want to promise it inside this RFC until I've done the
investigation.
- Diamond inheritance
Yea, sorry. Contravariant (parameter) positions are the ones that
merge cleanly into a union, not return positions. Your example is
right.
For the generic case, the contravariant side is the easy one:
interface Box<-T> { public function set(T $v): void; }
class C implements Box<int>, Box<string> { /* set(int|string) */ }
The implementer's substituted prototype is the union of the two
contravariant slots. No new type-system rules are needed.
The covariant side is more nuanced. get(): int and get(): string
merged would have to return both ( i.e. int & string ), and PHP
rejects intersections involving scalars (because impossible!). So a
covariant diamond with scalar bindings is unrepresentable. It only
becomes representable when the type parameter is bounded by an
object-shaped type, in which case the implementer's return type
collapses cleanly to an intersection:
interface Box<+T : object> { public function get(): T; }
interface A {}
interface B {}
class C implements Box<A>, Box<B> {
public function get(): A&B { /* ... */ }
}
Here A & B is a valid PHP intersection, so the merge is sound.
I'll look into this. I think we can fit it into this RFC, but I want
to investigate the implementation first. I keep the RFC and the
implementation in sync and don't want to commit to text that isn't
backed by working code yet.
Thanks,
Seifeddine.
Hi Bob,
Thanks for the quick reply!
Let me respond inline to avoid backtracking too much.
I have a bunch of questions and feedback:
The requirement of ordering seems unnecessary to me - why would we not want to be able to write <T: Box<U>, U: Box<T>>. Alternatingly recursive types are not unheard of. Seems like an arbitrary restriction; and for compilation purposes it only requires collecting all parameter names before evaluating them.
Your tests also show restrictions around intersection types, e.g. "Type parameter T with bound mixed cannot be part of an intersection type" for 'class Foo {} function x<T>(): T & Foo {}'. What's the motivation behind it? This looks fairly natural to me: x() promises to return an instance of Foo which also fulfills the bound T. Any child class of Foo which happens to implement T will fulfill that contract.
I would like to plead to skip the arity validation, except for "more parameters than allowed":
- This inhibits graceful addition of generics - any library adding them requires callers to immediately update all caller sites.
- It would also make addition of generics to Iterator classes etc. completely uncontroversial.
- This would be more in line with PHP's general "no type is effectively the highest possible bound" approach. I.e. "class A extends Box" and "class A extends Box<mixed>" would be equivalent.
- This would also allow for future incremental runtime generics: you'd start with <never> and as you call stuff with values, the type becomes broader.
This is the one thing which makes the whole RFC a non-starter for me if required:
Typing is optional in PHP!
Your tests show that this specific example is allowed, which strikes me as odd. Why would we not check the arity here?
class Container {}
function f(Container<int> $x): Container<string> { return $x; }Diamond checks:
Are these necessarily problematic? if you inherit Box<int> and Box<string>, it simply means that the generic parameter, when placed in a contravariant location will accept int|string, when placed into return or property types it'll evaluate to never.
If you disagree (that's possibly fine), a diamond covariant parameter should be allowed in any case though, i.e. if Box<+T>, then an interface shall be able to implement Box<string>, Box<int>. At least at a glance I don't find such a test - if it already works, nice, then please just add the test!
Is class ABox implements Box<self> allowed, or do we need to write implements Box<ABox>?
I'm also not sold on the turbofish syntax. I hate it in Rust, which I have to write nearly daily. I forget these :: SO often. And then the Linter yells at me and I correct it.
I understand that there are language limitations, in particular with the array syntax, but honestly, I'd rather just have the parser shift in favor of the existing syntax - for these rare conflicting cases forcing parenthesis around the generic would be nicer, i.e.[A<B, B>(C)]would continue carrying the meaning it has today, and we'd require writing[(A<B, B>(C))]for that case.I'm not quite sure if + and - are the proper choices. I'm more used to C# myself with in and out being more obvious to me. I also admit that I initially assumed "+" to be covariant - the sum of stuff accepted, and "-" contravariant, subtracting what can be returned. But this particular bikesheds color is not too important to me.
Otherwise, it's a pretty solid RFC which should be extensible with runtime generics eventually. (In particular runtime generics on the class inheritance level should be a no-brainer to add with the existing syntax.)
Thanks,
BobThanks for the careful read. Going point by point.
- Ordering of type parameter declarations
The restriction is implementation-level, not fundamental. We register
parameter names before we compile bounds, so allowing <T: Box<U>, U:
Box<T>> is a "small" change. I left it out for the initial cut because
I didn't want to bake mutually-recursive bounds into the spec without
seeing whether anyone actually wants them in practice. If others agree
this is worth having, I'm happy to drop the restriction before vote.Yes, that's the impression I had - an arbitrary restriction to make it a bit simpler at compile time.
I'd suggest just dropping it, why have it, actually? It should be a relatively easy change. I don't see any concrete advantage of this, apart from the minor simplification this restriction would have in compiler.
- Type parameters in intersection types
The check rejects an intersection where one side is a type parameter
whose bound ismixed, because the erased form can be anything,
including a scalar. Scalars don't intersect with anything, today. (
ref https://3v4l.org/mdvFA#v )The error message in the test you saw is precisely about the unbounded
case. IfTis bound to an object-shaped type (T: object,T: SomeInterface,T: SomeClass, ...), thenT & Foois allowed. the
erased form is guaranteed to be a legal intersection operand. So this
is the same rule PHP already enforces today, just applied through the
erased form.Ah, I see, it needs a T: object. (or named class). It's not quite obvious from the error message, so I'd suggest adding a suggestion for "at least T: object or a stronger bound" then.
That makes some sense. The question would be if never types should be possible to reached, but this I've basically asked already when asking about diamond checks.
- Arity validation at consumer call sites
I think this one is a misunderstanding. Arity validation only fires
when the caller writes turbofish. Without turbofish, nothing changes
at the call site:function id<T>(T $v): T { return $v; } id($x); // no validation, no behavior change id::<int>($x); // arity + bound checkedSo a library can add generic parameters to its public surface and
every existing caller (none of which uses turbofish, because turbofish
doesn't exist today) keeps working unchanged. The validation is opt-in
at the use site. Same fornewand method calls.This is exactly the graceful-addition story you're asking for. The
existing tests demonstrate it.This is not quite obvious from the RFC. I'd recommend adding a subsection to "What is enforced where" detailing that these are not checked: I thought "turbofish arity" would apply to everywhere, not just explicitly where the ::<> syntax is actually used.
Are they also not checked for inheritance? Or just for caller sites?
Sorry for missing it in tests, you have a LOT of tests!
- Generic args on a non-generic class in a signature
class Container {} function f(Container<int> $x): Container<string> { return $x; }This is accepted, and on purpose. PHP doesn't load classes from
signatures, they load on use: https://3v4l.org/DnIKQ#vTo validate arity at compile time, we'd have to load
Container,
which is a behavioral and performance regression. The cost of being
strict here is much higher than the cost of being permissive. The same
logic that already lets you reference an unloaded class in a signature
lets you reference an unloaded class with type arguments in a
signature. Validation happens once the class actually gets resolved at
a use site (new, turbofish call, etc.).I'm actually suggesting validation at runtime here, i.e. once the class type check passes, to check whether the arity is matching for the class of the argument.
I'm certainly not asking for compile time checks here. But leaving this unchecked sort-of makes it the odd-one out here.
- Diamond inheritance
The diamond check is necessary because methods get substituted with
the type arguments at link time. Consider:interface Box<T> { public function set(T $v): void; } class C implements Box<int>, Box<string> {}After substitution, C must implement both
set(int): voidand
set(string): void. PHP has no way to represent two methods with the
same name and different signatures ( i.e overloading ), one of them
has to win, and either choice silently breaks one of the parent
contracts. Same problem in contravariant position. The check rejects
this at link time rather than letting it produce a class that violates
its own interface.For purely covariant slots you have a point,
get(): intandget(): stringcould in principle be reconciled toget(): int|string(an
LUB). The current implementation rejects all diamonds uniformly to
keep linking deterministic and to avoid synthesizing union types
during inheritance. Relaxing it for the covariant case is a reasonable
follow-up, not something I want to bake in before vote.You got it the wrong way round, the union needs to be allowed on the parameters, not the return type.
set(string): void and set(int): void can be merged into set(string|int): void.
I'd also like to mention here that:
interface A { public function set(int $v): void; }
interface B { public function set(string $v): void; }
class C implements A, B { public function set(int|string $v): void {} }is perfectly valid today. Not allowing this for the contravariant case would make it inconsistent with what's currently supported in PHP.
This needs no overloading at all.
class ABox implements Box<self>It is allowed and works as you'd expect.
selfresolves to the
implementing class.interface Box<+T> { public function get(): T; } class ABox implements Box<self> { public function get(): self { return $this; } } var_dump((new ABox)->get() instanceof ABox); // trueNice!
- Turbofish
We have to disagree here. Turbofish:
- has zero parser conflict with comparison operators in expression position
- is uniform across
new, function calls, method calls, FCCs, attributes- requires no context-sensitive disambiguation rule
The alternative adds a rule a developer has to learn and apply at
exactly the worst places (inside attributes, array expressions,
ternaries). I'd rather pay the::tax than introduce a
context-sensitive parser rule that bites people inside attributes
specifically. Rust's choice was a forced one because of<>overload,
and it's the right one for PHP too for the same reason.Alright, let's disagree here.
- / - markers
Picked because they don't require any new reserved words.
in/out
reads well but I'm not comfortable burning two keywords for a feature
where two pieces of punctuation already do the job.On the "+ = sum of accepted" intuition: the convention here is the
standard one from variance literature.+marks positions where the
type can be widened (covariant, e.g., returns),-marks positions
where it can be narrowed (contravariant, e.g., parameters). It also
matches Hack, Scala, and Kotlin, so there is prior art the ecosystem
already maps to.I understand, I've never used any of those languages for more than targeted edits, so I didn't know.
I guess it's fine to not diverge here.
By the way, you don't necessarily need a new keyword, in fact you could just allow two consecutive
T_STRINGat that position and emit a parser error when the first one is neither of "in" or "out".Thanks,
BobThanks for the reply!
- Ordering of type parameter declarations
Agreed, dropped. Forward references and mutually recursive bounds
within a single parameter list are now allowed:function f<U : T, T>(U $x): T { /* ... */ } // forward class Pair<T : Box<U>, U : Box<T>> {} // mutualDefaults still require backward-only references, meaning omitted
arguments resolve in one pass at instantiation. Direct self-reference
at the head of a bound (<T : T>) is still rejected; the indirect
form (<T : Box<T>>) is still allowed.
- Intersection error message
Improved the diagnostic to point directly at the fix instead of just
stating the rule. The message now reads:Type parameter T with bound mixed cannot be part of an intersection type; use an object-shaped bound (e.g. T: object)Diff:
Thanks for the reply!
- Ordering of type parameter declarations
Agreed, dropped. Forward references and mutually recursive bounds
within a single parameter list are now allowed:function f<U : T, T>(U $x): T { /* ... */ } // forward class Pair<T : Box<U>, U : Box<T>> {} // mutualDefaults still require backward-only references, meaning omitted
arguments resolve in one pass at instantiation. Direct self-reference
at the head of a bound (<T : T>) is still rejected; the indirect
form (<T : Box<T>>) is still allowed.
- Intersection error message
Improved the diagnostic to point directly at the fix instead of just
stating the rule. The message now reads:Type parameter T with bound mixed cannot be part of an intersection type; use an object-shaped bound (e.g. T: object)
- Arity validation at consumer call sites
You're right that the RFC didn't say this clearly. Added a "What is
not checked" subsection under "What is enforced where" that lists the
exact sites where the engine intentionally omits arity or bounds
validation. The whole point of opt-in is the graceful-addition story
you're after; the RFC now spells that out.
- Runtime arity check at call boundaries
In principle, sure. Once the runtime confirms the value matches the
class, we can also validate the signature's type arguments against the
class's actual declared arity and bounds. So in:class C {} function foo(C<int, string> $x): void {} foo(new C());we'd error because
Chas no generic parameters but the signature
supplied two type arguments.The catch is that this isn't only about parameters. It applies to
every place the engine resolves a class-typed type expression at
runtime.So a bit more complicated, not a small change. I want to spend more
time on it before committing to text: what exactly gets validated,
where the result gets cached so we aren't paying for it on every typed
call, how it interacts with the substitution chain at link time, and
what the hot-path cost actually is on a profiled workload. I'd like to
see what others on the list think too, since the call is a trade-off
between strictness and performance and people will weigh those
differently.If after that the answer is "yes, fold it in", I'll fold it in. But I
don't want to promise it inside this RFC until I've done the
investigation.
- Diamond inheritance - I had the direction wrong
Yea, sorry. Contravariant (parameter) positions are the ones that
merge cleanly into a union, not return positions. Your example is
right.For the generic case, the contravariant side is the easy one:
interface Box<-T> { public function set(T $v): void; } class C implements Box<int>, Box<string> { /* set(int|string) */ }The implementer's substituted prototype is the union of the two
contravariant slots.The covariant side is more nuanced.
get(): intandget(): string
merged would have to return both ( i.e.int & string), and PHP
rejects intersections involving scalars (because impossible!). So a
covariant diamond with scalar bindings is unrepresentable. It only
becomes representable when the type parameter is bounded by an
object-shaped type, in which case the implementer's return type
collapses cleanly to an intersection:interface Box<+T : object> { public function get(): T; } interface A {} interface B {} class C implements Box<A>, Box<B> { public function get(): A&B { /* ... */ } }Here
A & Bis a valid PHP intersection, so the merge is sound.I'll look into this. I think we can fit it into this RFC, but I want
to investigate the implementation first. I keep the RFC and the
implementation in sync and don't want to commit to text that isn't
backed by working code yet.Thanks,
Seifeddine.
- Arity validation at consumer call sites
You're right that the RFC didn't say this clearly. Added a "What is
not checked" subsection under "What is enforced where" that lists the
exact sites where the engine intentionally omits arity or bounds
validation. The whole point of opt-in is the graceful-addition story
you're after; the RFC now spells that out.
- Runtime arity check at call boundaries
In principle, sure. Once the runtime confirms the value matches the
class, we can also validate the signature's type arguments against the
class's actual declared arity and bounds. So in:class C {} function foo(C<int, string> $x): void {} foo(new C());we'd error because
Chas no generic parameters but the signature
supplied two type arguments.The catch is that this isn't only about parameters. It applies to
every place the engine resolves a class-typed type expression at
runtime.So a bit more complicated, not a small change. I want to spend more
time on it before committing to text: what exactly gets validated,
where the result gets cached so we aren't paying for it on every typed
call, how it interacts with the substitution chain at link time, and
what the hot-path cost actually is on a profiled workload. I'd like to
see what others on the list think too, since the call is a trade-off
between strictness and performance and people will weigh those
differently.If the answer after that is "yes, add it,", I'll fold it in. But I
don't want to promise it inside this RFC until I've done the
investigation.
- Diamond inheritance
Yea, sorry. Contravariant (parameter) positions are the ones that
merge cleanly into a union, not return positions. Your example is
right.For the generic case, the contravariant side is the easy one:
interface Box<-T> { public function set(T $v): void; } class C implements Box<int>, Box<string> { /* set(int|string) */ }The implementer's substituted prototype is the union of the two
contravariant slots. No new type-system rules are needed.The covariant side is more nuanced.
get(): intandget(): string
merged would have to return both ( i.e.int & string), and PHP
rejects intersections involving scalars (because impossible!). So a
covariant diamond with scalar bindings is unrepresentable. It only
becomes representable when the type parameter is bounded by an
object-shaped type, in which case the implementer's return type
collapses cleanly to an intersection:interface Box<+T : object> { public function get(): T; } interface A {} interface B {} class C implements Box<A>, Box<B> { public function get(): A&B { /* ... */ } }Here
A & Bis a valid PHP intersection, so the merge is sound.I'll look into this. I think we can fit it into this RFC, but I want
to investigate the implementation first. I keep the RFC and the
implementation in sync and don't want to commit to text that isn't
backed by working code yet.Thanks,
Seifeddine.
Hi Bob,
Pulled an all-nighter on #5 because the more I thought about it the
more it seemed like the right thing to do, and it turned out to
compose really cleanly with the existing parametric LSP machinery.
Both directions are now in:
Contravariant diamond -> union merge
Covariant diamond on object-bounded T -> intersection merge
The RFC has a new "Diamond inheritance" subsection covering both:
https://wiki.php.net/rfc/bound_erased_generic_types#diamond_inheritance
Genuinely happy with how this turned out. Example from the RFC:
interface Renderable {}
interface Cacheable {}
class Article implements Renderable, Cacheable {}
interface Pipeline<T : object> {
public function process(T $value): T;
}
interface RenderingPipeline extends Pipeline<Renderable> {}
interface CachingPipeline extends Pipeline<Cacheable> {}
class ArticlePipeline implements
RenderingPipeline,
CachingPipeline,
Pipeline<Renderable & Cacheable>
{
public function process(Renderable | Cacheable $value): Renderable
& Cacheable {
if ($value instanceof Renderable && $value instanceof Cacheable) {
return $value;
}
return new Article();
}
}
The synthesized prototype across the three views is
process(Renderable | Cacheable): Renderable & Cacheable. The merge
is computed at link time against PHP's existing union and intersection
operators, so no new type-system rules are needed.
Thanks for pushing on this :D
Cheers,
Seifeddine.
On Sun, May 10, 2026 at 1:05 PM Seifeddine Gmati
azjezz@carthage.software wrote:
Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.
- RFC: https://wiki.php.net/rfc/bound_erased_generic_types
- Implementation: https://github.com/php/php-src/pull/21969
Thanks,
Seifeddine.
Seifeddine,
This is a very interesting RFC, thank you!
I have a technical request: can we lower the limit from 255 to a 7-bit
max? I've done tons of optimization work in the last 8-10 years of my
life, and having a spare bit on things for the future has often been
rewarded. And for me, I can't imagine a use-case for having 128-255
type arguments in practice. Do you have any evidence that a 7-bit
maximum would be insufficient for real-world code?
Thanks, Levi Morrison
On Sun, May 10, 2026 at 1:05 PM Seifeddine Gmati
azjezz@carthage.software wrote:Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.
- RFC: https://wiki.php.net/rfc/bound_erased_generic_types
- Implementation: https://github.com/php/php-src/pull/21969
Thanks,
Seifeddine.Seifeddine,
This is a very interesting RFC, thank you!
I have a technical request: can we lower the limit from 255 to a 7-bit
max? I've done tons of optimization work in the last 8-10 years of my
life, and having a spare bit on things for the future has often been
rewarded. And for me, I can't imagine a use-case for having 128-255
type arguments in practice. Do you have any evidence that a 7-bit
maximum would be insufficient for real-world code?Thanks, Levi Morrison
Hi Levi,
Good catch, lowered. The cap is now 127 (u7), with the high bit
reserved for future per-site metadata. RFC updated:
https://wiki.php.net/rfc/bound_erased_generic_types#the_127-argument_cap
Honestly, there is no real-world evidence either way, no PHP codebase
approaches either 127 or 255 type parameters, so this is purely about
reserving headroom.
Thanks,
Seifeddine.
Thank you, Seifeddine, for all this work and effort; it was a very clear
and even pleasant read. To internals: I'd like to highlight a couple of
additional reasons why I think you should vote yes on this final RFC.
- Although runtime-ignored generics are a paradigm shift compared to PHP's
current type system, it's important to note that the target audience for
this feature is already very familiar with this workflow. The value of
generics (and by extension, the whole type system) for them comes from
static type analysis, not the runtime type checker. Why do we need
dedicated syntax then, and not stick with docblocks? Because of the reasons
outlined in the RFC: a consistent spec and consistent syntax. - Regarding the consistent specification, you may know I work at PhpStorm,
so I'd like to highlight how much of a pain the current situation is for us
(and also other static analysis vendors). There is no consistency in the
details. We have a huge backlog of issues regarding generic type checking
that are unsolvable without a proper and consistent spec. PHPStan does X,
Psalm does Y, Mago does Z; and most developers expect PhpStorm to support
everything. Furthermore, performance is much more a concern for us, as we
run our type checker in real time. We would love to improve our generic
type support, but a proper spec is required to ensure consistency and a
clear path forward. Full disclosure, we tried bringing all static analysis
vendors together five or six years ago to create this consistent spec
ourselves. These efforts failed, and the only viable option we see is if
the spec came from internals. - Regarding adding new syntax that doesn't really do anything, there is
precedent with attributes. Similar to generics, annotations were a
docblock-only feature that got dedicated syntax, without any runtime effect
besides reflection. That's exactly the mindset this RFC embraces, and
attributes were very well received by the PHP community. I understand some
people may fear runtime-ignored generics causing confusion, but I don't
think this will be a problem, given that the target audience is: one, used
to this workflow already; and, two, attributes already introduced this
concept of "syntax that has no runtime effect".
I hope that, along with all the arguments Seifeddine made in the RFC, you
seriously consider voting yes on the final RFC, even if you yourself aren't
the target audience for this feature. The vast majority of people who would
benefit from generics are the people already using static analysis.
Speaking with them for years both online and offline, I know most are on
board with this approach. (I say "most of them", but truthfully, everyone I
spoke with over the years is on board. I just avoid saying "all" because
I'm sure someone somewhere disagrees).
Have a good day!
Brent
On Sun, May 10, 2026 at 9:04 PM Seifeddine Gmati azjezz@carthage.software
wrote:
Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.Generic type parameters can be declared on classes, interfaces,
traits, functions, methods, closures, and arrow functions, with
bounds, defaults, and variance markers. Type parameters erase to their
bound at runtime; the pre-erasure form is preserved for Reflection and
consumed by static analyzers.
- RFC: https://wiki.php.net/rfc/bound_erased_generic_types
- Implementation: https://github.com/php/php-src/pull/21969
Thanks,
Seifeddine.
Hello Internals,
I'd like to start the discussion on a new RFC adding bound-erased
generics types to PHP.
Hi Seifeddine,
Thanks for putting together this proposal.
However, I am generally against any unenforced implementation of generics. I choose that word carefully: it is not type erasure per se that I am against, but the ability to write code which looks like it enforces particular signatures, but actually does not.
There is lots of background in the RFC, but one relevant comparison not referenced is Python, where all type hints are unenforced. Static analysis has to be run manually, so code can be written, and even published as a library, with completely incorrect type information. Users of that code then make reasonable assumptions based on the published types, and get unexpected run-time behaviour.
Note that this is fundamentally different from the experience of languages like Java or TypeScript, where type information may be erased, but is still enforced, because static analysis is a compulsory part of the compilation process.
Having most types enforced is obviously better than none, but as far as I can see the same risk exists - someone can write code that claims to use generics, and publish it to Packagist without actually checking it; or write supposed invariants which only hold if the user also analyses their own code. This is true today, of course, but the claims are much weaker, sitting in docblocks not native syntax.
Once we go down this route, it will be very hard to change our minds, because enforcing types in code which previously ignored them would be a breaking change.
I think the only options I would personally support are:
a) run-time enforced generics in limited positions (as in the blog post you link to from Gina and Larry)
b) generics which are erased at compile-time by a tool that also enforces them (as in Java, TypeScript, etc)
Regards,
Rowan Tommins
[IMSoP]