[PHP-DEV] bikeshed: Typed Aliases

Hello Internals,

I’m going to try something new. I’ve been working on another RFC called “Typed Aliases” (https://wiki.php.net/rfc/typed-aliases). It is very much a draft and in-flux, and I’ve already worked out the technical mechanics of it … however, I am very unhappy with the syntax, and while I have a few ideas about that; I assume this list will be much better at it than me. So, please bring your brushes and paint; I brought a bikeshed.

If you haven’t read it already, here’s the TL;DR:

  • This RFC expands the “use … as …” syntax to allow any type expression on the left side. These aliases are PER FILE and expire once the file is compiled.

  • This RFC also adds the ability for an alias to survive a file (currently using the “as alias” syntax that I don’t like) which actually just creates a special kind of class. When this special class is encountered during type-checking, the alias is expanded and checked. It also allows this via a “type_alias()” function instead of the “use … as alias …” syntax.

How it works:

use string as alias MyString

gets virtually compiled into a special class that would look something like this to ReflectionClass (as it is currently):

class MyString extends PrimitiveAlias {

const PrimitiveType aliasOf = PrimitiveType::string;

}

  • Reflection is a bit weird here, and I’m not exactly happy with it; but I’m curious what the list thinks. I’m open to virtually anything that makes sense here; including not allowing ReflectionClass on the type aliases at all.

  • Since these are “technically” classes, I went with just “use”-ing them like normal classes. Originally, I had something different: “use alias …” (like “use function …”) to make it more clear. I will probably go back to this, but I’m curious what others think.

I’m going to take a step back and listen/answer questions. But please, grab a brush and paint.

— Rob

On Fri, Sep 6, 2024, at 1:41 PM, Rob Landers wrote:

Hello Internals,

I'm going to try something new. I've been working on another RFC called
"Typed Aliases" (PHP: rfc:typed-aliases). It is very
much a draft and in-flux, and I've already worked out the technical
mechanics of it ... however, I am very unhappy with the syntax, and
while I have a few ideas about that; I assume this list will be much
better at it than me. So, please bring your brushes and paint; I
brought a bikeshed.

If you haven't read it already, here's the TL;DR:

- This RFC expands the "use ... as ..." syntax to allow any type
expression on the left side. These aliases are PER FILE and expire once
the file is compiled.

- This RFC also adds the ability for an alias to survive a file
(currently using the "as alias" syntax that I don't like) which
actually just creates a special kind of class. When this special class
is encountered during type-checking, the alias is expanded and checked.
It also allows this via a "type_alias()" function instead of the "use
... as alias ..." syntax.

How it works:

use string as alias MyString

gets virtually compiled into a special class that would look something
like this to ReflectionClass (as it is currently):

class MyString extends PrimitiveAlias {
  const PrimitiveType aliasOf = PrimitiveType::string;
}

- Reflection is a bit weird here, and I'm not exactly happy with it;
but I'm curious what the list thinks. I'm open to virtually anything
that makes sense here; including not allowing ReflectionClass on the
type aliases at all.

- Since these are "technically" classes, I went with just "use"-ing
them like normal classes. Originally, I had something different: "use
alias ..." (like "use function ...") to make it more clear. I will
probably go back to this, but I'm curious what others think.

I'm going to take a step back and listen/answer questions. But please,
grab a brush and paint.

— Rob

Hi Rob.

First of all, I'm very much in favor of type aliases generally, so thank you for taking a swing at this.

Second, it looks like you've run into the main design issue that has always prevented them in the past: Should aliases be file-local and thus not reusable, or global and thus we need to figure out autoloading for them? It looks like your answer to that question at the moment is "yes". :slight_smile: While I can see the appeal, I don't think that's the best approach. Or rather, if we go that route, they shouldn't be quite so similar syntactically.

There seems to be two different implementations living in the same RFC, uncomfortably. In one, it's a compiler-time replacement. In the other, it's a special class-like. But the RFC seems to go back and forth on what happens in which case, and I'm not sure which is which.

However, you have demonstrated a working class-like for it, which is frankly the biggest hurdle. So I think the direction has promise, but should be adjusted to go all-in on that approach.

To wit:

typealias Stringy: string|Stringable;
typealias UserID: Int;
typealias TIme: Hour|Minute|Second;
typealias FilterCallback: callable(mixed $a): bool; (eventually...)

(etc.)

Each of those produces a class-like, which can therefore be autoloaded like a class. The syntax is also a bit closer to a class (or an Enum, I suppose), so it's much more self-evident that they are defining a reusable thing (whereas "use" does not do that currently). And the syntax is not stringy, like the proposed type_alias(), so it's easier to write. I wouldn't even include type_alias() at that point. It exists at runtime, so reflection is meaningful.

Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.

(Whether the keyword is typealias or typedef, uses : or =, or whatever, is its own bikeshed I won't dive into at the moment.)

Then, as a separate, entirely optional, maybe even separate RFC (or second vote, or whatever), we have a `use string|Stringable as Stringy` syntax. Like all other `use` declarations, these are compile-time only, single-file only, and do not exist at runtime, so no reflection. They compile away just like all other use-statements now.

I'm not personally convinced the second is really necessary if we do a good enough job on the first, but I'd probably not stand in the way of having both.

Having typealias/typedef as a class-like also opens up some interesting potential in the future, because classes have all sorts of other things they do, but that is probably too complex scope creepy to get into here so I will not go further than that mention.

I suspect there's also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:

typealias Foo: (Bar&Baz)|Beep;

use (Bar&Baz)|Beep as Foo;

function narf(Foo&Stringable $s) {}

With the compile time approach, that would expand to `(Bar&Baz)|Beep&Stringable`, which is not a valid type def.

With the runtime approach, I don't know if that could be handled gracefully or if it would still cause an error.

I'm not sure what the right solution is on this one, just pointing it out as a thing to resolve.

--Larry Garfield

On Fri, Sep 6, 2024, at 22:45, Larry Garfield wrote:

Hi Rob.

First of all, I’m very much in favor of type aliases generally, so thank you for taking a swing at this.

Second, it looks like you’ve run into the main design issue that has always prevented them in the past: Should aliases be file-local and thus not reusable, or global and thus we need to figure out autoloading for them? It looks like your answer to that question at the moment is “yes”. :slight_smile: While I can see the appeal, I don’t think that’s the best approach. Or rather, if we go that route, they shouldn’t be quite so similar syntactically.

There seems to be two different implementations living in the same RFC, uncomfortably. In one, it’s a compiler-time replacement. In the other, it’s a special class-like. But the RFC seems to go back and forth on what happens in which case, and I’m not sure which is which.

However, you have demonstrated a working class-like for it, which is frankly the biggest hurdle. So I think the direction has promise, but should be adjusted to go all-in on that approach.

To wit:

typealias Stringy: string|Stringable;

typealias UserID: Int;

typealias TIme: Hour|Minute|Second;

typealias FilterCallback: callable(mixed $a): bool; (eventually…)

(etc.)

Each of those produces a class-like, which can therefore be autoloaded like a class. The syntax is also a bit closer to a class (or an Enum, I suppose), so it’s much more self-evident that they are defining a reusable thing (whereas “use” does not do that currently). And the syntax is not stringy, like the proposed type_alias(), so it’s easier to write. I wouldn’t even include type_alias() at that point. It exists at runtime, so reflection is meaningful.

Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.

(Whether the keyword is typealias or typedef, uses : or =, or whatever, is its own bikeshed I won’t dive into at the moment.)

Then, as a separate, entirely optional, maybe even separate RFC (or second vote, or whatever), we have a use string|Stringable as Stringy syntax. Like all other use declarations, these are compile-time only, single-file only, and do not exist at runtime, so no reflection. They compile away just like all other use-statements now.

I’m not personally convinced the second is really necessary if we do a good enough job on the first, but I’d probably not stand in the way of having both.

That’s a really good point and would clear up quite a bit of confusion and complexity.

Having typealias/typedef as a class-like also opens up some interesting potential in the future, because classes have all sorts of other things they do, but that is probably too complex scope creepy to get into here so I will not go further than that mention.

I suspect there’s also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:

Oh, DNF is the bane of my existence with this RFC—I don’t want to mess this up. I’ll see you at the end of the example, though.

typealias Foo: (Bar&Baz)|Beep;

use (Bar&Baz)|Beep as Foo;

function narf(Foo&Stringable $s) {}

With the compile time approach, that would expand to (Bar&Baz)|Beep&Stringable, which is not a valid type def.

I can see how you arrived at this, but I think you may have missed a step, since the entirety of Foo will be &'d with Stringable.

Foo = (Bar & Baz) | Beep

want: (Foo) & Stringable

expand Foo: ((Bar & Baz) | Beep) & Stringable

Which can be reduced to the following in proper DNF (at least, it compiles—https://3v4l.org/0bMlP):

(Beep & Stringable) | (Bar & Baz & Stringable)

It’s probably a good idea to update the RFC explaining how expansion works.

With the runtime approach, I don’t know if that could be handled gracefully or if it would still cause an error.

I’m not sure what the right solution is on this one, just pointing it out as a thing to resolve.

–Larry Garfield

— Rob

On Fri, Sep 6, 2024, at 6:27 PM, Rob Landers wrote:

I suspect there's also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:

Oh, DNF is the bane of my existence with this RFC—I don't want to mess
this up. I'll see you at the end of the example, though.

typealias Foo: (Bar&Baz)|Beep;

use (Bar&Baz)|Beep as Foo;

function narf(Foo&Stringable $s) {}

With the compile time approach, that would expand to `(Bar&Baz)|Beep&Stringable`, which is not a valid type def.

I can see how you arrived at this, but I think you may have missed a
step, since the entirety of Foo will be &'d with Stringable.

Foo = (Bar & Baz) | Beep

want: (Foo) & Stringable

expand Foo: ((Bar & Baz) | Beep) & Stringable

Which can be reduced to the following in proper DNF (at least, it
compiles—Online PHP editor | output for 0bMlP):

(Beep & Stringable) | (Bar & Baz & Stringable)

It's probably a good idea to update the RFC explaining how expansion works.

Woof. We're not "fixingup" anyone's DNF elsewhere. I cannot speak for everyone, but I'd be perfectly fine not doing any magic fixing for now, and then debating separately if we should do it more generally. Just doing it for aliases doesn't seem like the best plan.

--Larry Garfield

On Sat, Sep 7, 2024, at 01:34, Larry Garfield wrote:

On Fri, Sep 6, 2024, at 6:27 PM, Rob Landers wrote:

I suspect there’s also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:

Oh, DNF is the bane of my existence with this RFC—I don’t want to mess

this up. I’ll see you at the end of the example, though.

typealias Foo: (Bar&Baz)|Beep;

use (Bar&Baz)|Beep as Foo;

function narf(Foo&Stringable $s) {}

With the compile time approach, that would expand to (Bar&Baz)|Beep&Stringable, which is not a valid type def.

I can see how you arrived at this, but I think you may have missed a

step, since the entirety of Foo will be &'d with Stringable.

Foo = (Bar & Baz) | Beep

want: (Foo) & Stringable

expand Foo: ((Bar & Baz) | Beep) & Stringable

Which can be reduced to the following in proper DNF (at least, it

compiles—https://3v4l.org/0bMlP):

(Beep & Stringable) | (Bar & Baz & Stringable)

It’s probably a good idea to update the RFC explaining how expansion works.

Woof. We’re not “fixingup” anyone’s DNF elsewhere. I cannot speak for everyone, but I’d be perfectly fine not doing any magic fixing for now, and then debating separately if we should do it more generally. Just doing it for aliases doesn’t seem like the best plan.

–Larry Garfield

Oh, we’re definitely not “fixingup” the expression to DNF… more like spending some time in the RFC showing how the expansion is the same execution as with a DNF expression to prove that it is a valid type expression.

— Rob

On Sep 6, 2024, at 16:40, Rob Landers rob@bottled.codes wrote:

On Sat, Sep 7, 2024, at 01:34, Larry Garfield wrote:

On Fri, Sep 6, 2024, at 6:27 PM, Rob Landers wrote:

I suspect there’s also other edge case bits to worry about, particularly if trying to combine a complex alias with a complex type, which could lead to violating the DNF rule. For example:

Oh, DNF is the bane of my existence with this RFC—I don’t want to mess

this up. I’ll see you at the end of the example, though.

typealias Foo: (Bar&Baz)|Beep;

use (Bar&Baz)|Beep as Foo;

function narf(Foo&Stringable $s) {}

With the compile time approach, that would expand to (Bar&Baz)|Beep&Stringable, which is not a valid type def.

I can see how you arrived at this, but I think you may have missed a

step, since the entirety of Foo will be &'d with Stringable.

Foo = (Bar & Baz) | Beep

want: (Foo) & Stringable

expand Foo: ((Bar & Baz) | Beep) & Stringable

Which can be reduced to the following in proper DNF (at least, it

compiles—https://3v4l.org/0bMlP):

(Beep & Stringable) | (Bar & Baz & Stringable)

It’s probably a good idea to update the RFC explaining how expansion works.

Woof. We’re not “fixingup” anyone’s DNF elsewhere. I cannot speak for everyone, but I’d be perfectly fine not doing any magic fixing for now, and then debating separately if we should do it more generally. Just doing it for aliases doesn’t seem like the best plan.

–Larry Garfield

Oh, we’re definitely not “fixingup” the expression to DNF… more like spending some time in the RFC showing how the expansion is the same execution as with a DNF expression to prove that it is a valid type expression.

— Rob

My main struggle with this is readability. As much as I want custom types (and type aliases is a good chunk of the way there), the main issue I have is understanding what the valid inputs are:

function foo(Status $string): void { }

How do I know that Status is a) not a class, b) that I can fulfill the requirement with a string, and/or maybe any object with __toString(), or maybe it’s ints? Or objects or enums?

Even with file-local aliases (which I would definitely prefer to avoid) we will most likely rely on developer tooling (e.g. IDEs and static analyzers) to inform the developer what the right input types are.

I would very much prefer to either go all in with an Enum-like (which means that we can hang methods on to the value) or we need to distinguish between type hints for class-likes and type hints for not-class-likes (*Bar anyone?).

Expanding on type-class-likes: within the type methods, $this->value would refer to the original value, any operators would follow the same rules as either the original values type (e.g. $int = 4; $string = “foo”; $int . $string == “4foo", or call __toString() in all the normal places for strings if defined).

type Stringable: string|int {
public function __toString(): string
{
return (string) $this->value; // original value
}

// Add Stringable methods here
}.

So, with that in mind… I’d also like to open up the ability for Enums to be fulfilled by the backed value, that is:

function foo(Bar $bar): void { }

Where Bar is:

enum Bar: string {

case BAZ = ‘baz’;
case BAT = ‘bat’;

}

And you can call foo() like: foo(‘baz’) and Bar::BAZ will be passed in.

I realize I’m opening a barn down here, but I just don’t see file-local type aliases as that useful, and while I like the functionality of type-class-likes, I think they would add more non-class behavior (in addition to enums) for things that look like classes if we don’t add some sort of identifier. I’d much rather that we add backed-value to enum casting, and at least make that more consistent with this functionality if we’re going to conflate the syntax.

  • Davey

Explicitly without expressing any opinion about the RFC, I’d just like to remind you that use... import statements for classes and such are not actually per file, but per namespace and making the use ... statements for types behave differently would be very inconsistent and surprising behaviour. These are the rules (as far as I know and based on extensive testing from my side): * No namespace - use imports apply to whole file. * Curly brace scoped namespace - use imports apply only to the code within the current namespace scope. * Single unscoped namespace - use imports apply to whole file (as the whole file is within the single unscope namespace) * Multiple unscoped namespaces - use imports apply only until the next namespace declaration is encountered. Having type use behave differently to other import use statements would, I imagine, also bring up problems around “file contains 2 scoped namespaces, type use is for the file, but - as things currently are - no code is allowed in the file outside the scoped namespaces, so where to place the import for the type ? and what would it then apply to ?” Smile, Juliette

···

On 6-9-2024 20:41, Rob Landers wrote:

  • This RFC expands the “use … as …” syntax to allow any type expression on the left side. These aliases are PER FILE and expire once the file is compiled.

On Sat, Sep 7, 2024, at 05:17, Juliette Reinders Folmer wrote:

On 6-9-2024 20:41, Rob Landers wrote:

  • This RFC expands the “use … as …” syntax to allow any type expression on the left side. These aliases are PER FILE and expire once the file is compiled.

Explicitly without expressing any opinion about the RFC, I’d just like to remind you that use... import statements for classes and such are not actually per file, but per namespace and making the use ... statements for types behave differently would be very inconsistent and surprising behaviour.

These are the rules (as far as I know and based on extensive testing from my side):

  • No namespace - use imports apply to whole file.

  • Curly brace scoped namespace - use imports apply only to the code within the current namespace scope.

  • Single unscoped namespace - use imports apply to whole file (as the whole file is within the single unscope namespace)

  • Multiple unscoped namespaces - use imports apply only until the next namespace declaration is encountered.

Having type use behave differently to other import use statements would, I imagine, also bring up problems around “file contains 2 scoped namespaces, type use is for the file, but - as things currently are - no code is allowed in the file outside the scoped namespaces, so where to place the import for the type ? and what would it then apply to ?”

Smile,

Juliette

Thanks Juliette,

I meant “as they are now” and not literally “per file” as described in the RFC. I will make that more clear in the RFC. When I wrote it, I was thinking in terms of how I usually write namespaced code and thought of it as “per file” but that is probably the wrong mental model in any case. Thanks again for pointing this out.

— Rob

On Sep 6, 2024, at 4:45 PM, Larry Garfield <larry@garfieldtech.com> wrote:
Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.

Is there a strong technical reason why extends and implements should be out of scope?

There is definite utility for this, to create a local alias in a namespace that can be used throughout the namespace rather than having to refer to the external namespace in many different places.

On Sep 6, 2024, at 8:46 PM, Davey Shafik <davey@php.net> wrote:
I would very much prefer to either go all in with an Enum-like (which means that we can hang methods on to the value) or we need to distinguish between type hints for class-likes and type hints for not-class-likes (*Bar anyone?).

Allowing methods also have definite value as most use-cases I have seen in other languages alias in order to add methods, especially for enabling support of interfaces.

Which, however, brings up an important distinction that other languages have made and which I think PHP would benefit from addressing:

1. Type Alias => Different Name for Same Type
2. Type Def => New Type which has all the same properties and methods of other type

e.g. (being hypothetical with the syntax; bikeshed away):

typealias LocalWidget: Widget

typedef MyWidget: Widget {
   function foo() {...}
}

function doSomething(Widget $widget) {...}

$w = new LocalWidget;
doSomething($w); // This works, no problem as LocalWidget === Widget

$w = new MyWidget;
doSomething($w); // This throws an error as MyWidget !== Widget

-Mike

On Sat, Sep 7, 2024, at 7:42 AM, Mike Schinkel wrote:

On Sep 6, 2024, at 4:45 PM, Larry Garfield <larry@garfieldtech.com> wrote:
Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.

Is there a strong technical reason why extends and implements should be
out of scope?

There is definite utility for this, to create a local alias in a
namespace that can be used throughout the namespace rather than having
to refer to the external namespace in many different places.

Because it quickly can produce nonsensical syntax.

class A {}
class B {}

typealias AB: A|B;

// This is logically nonsensical.
class C extends AB {}

While there are edge cases where that might be logical (if A and B are interfaces and &-ed together, then it's kinda sorta the same as C implements A, B), separating those out and allowing just that subset sounds like a lot of work for dubious gain, and introducing surprise inconsistency. Better to just avoid that entirely.

--Larry Garfield

On Fri, Sep 6, 2024, at 7:46 PM, Davey Shafik wrote:

My main struggle with this is readability. As much as I want custom
types (and type aliases is a good chunk of the way there), the main
issue I have is understanding what the valid inputs are:

function foo(Status $string): void { }

How do I know that Status is a) not a class, b) that I can fulfill the
requirement with a string, and/or maybe any object with __toString(),
or maybe it’s ints? Or objects or enums?

Even with file-local aliases (which I would definitely prefer to avoid)
we will most likely rely on developer tooling (e.g. IDEs and static
analyzers) to inform the developer what the right input types are.

I would very much prefer to either go all in with an Enum-like (which
means that we can hang methods on to the value) or we need to
distinguish between type hints for class-likes and type hints for
not-class-likes (*Bar anyone?).

Expanding on type-class-likes: within the type methods, $this->value
would refer to the original value, any operators would follow the
_same_ rules as either the original values type (e.g. $int = 4; $string
= “foo”; $int . $string == “4foo", or call __toString() in all the
normal places for strings if defined).

type Stringable: string|int {
     public function __toString(): string
     {
          return (string) $this->value; // original value
     }

     // Add Stringable methods here
}.

Methods on typedefs was the sort of "other stuff classes do" that I was trying to avoid getting into right now. :slight_smile: Mainly because I can totally see how it's tempting, but also have no idea what it would mean from a type-theoretic perspective. It would only make sense if we're talking type DEFs, not type ALIASes. I'm not against that, and it could be fun to try and think through the type theoretical implications, but I don't think that's what Rob was going for so I didn't want to take that side quest just yet. (Though if he's OK with it, I'm OK with it.)

So, with that in mind… I’d also like to open up the ability for Enums
to be fulfilled by the backed value, that is:

This is

1. Off topic for type aliases.
2. Has been discussed numerous times before. Enums are not equivalent to their backing value. The backing value is a standardized-serialization value. Nothing more. A string-backed enum is not a string, and a string is not a string-backed enum. Trying to use an enum as transparently equivalent to its backing value is a categorical error and belies a misunderstanding of what Enums are.

cf: On the use of enums | PeakD

--Larry Garfield

On Sat, Sep 7, 2024, at 14:42, Mike Schinkel wrote:

On Sep 6, 2024, at 4:45 PM, Larry Garfield <larry@garfieldtech.com> wrote:

Aliases can then be used only in parameter, return, property, and instanceof types. Extends and implements are out of scope entirely.

Is there a strong technical reason why extends and implements should be out of scope?

There is definite utility for this, to create a local alias in a namespace that can be used throughout the namespace rather than having to refer to the external namespace in many different places.

On Sep 6, 2024, at 8:46 PM, Davey Shafik <davey@php.net> wrote:

I would very much prefer to either go all in with an Enum-like (which means that we can hang methods on to the value) or we need to distinguish between type hints for class-likes and type hints for not-class-likes (*Bar anyone?).

Allowing methods also have definite value as most use-cases I have seen in other languages alias in order to add methods, especially for enabling support of interfaces.

Which, however, brings up an important distinction that other languages have made and which I think PHP would benefit from addressing:

  1. Type Alias => Different Name for Same Type

  2. Type Def => New Type which has all the same properties and methods of other type

e.g. (being hypothetical with the syntax; bikeshed away):

typealias LocalWidget: Widget

typedef MyWidget: Widget {

function foo() {…}

}

function doSomething(Widget $widget) {…}

$w = new LocalWidget;

doSomething($w); // This works, no problem as LocalWidget === Widget

$w = new MyWidget;

doSomething($w); // This throws an error as MyWidget !== Widget

-Mike

Hey Mike,

Keep in mind there is already single-class aliasing with well-known behavior for both local and project-wide aliases. Local aliases are done through ‘use’ statements, and project-wide aliases can be created by using the class_alias() function.

I feel like any aliasing for primitive/intersection/union types should feel like an extension of that for local aliases. For ‘project-wide’ aliases, I’m open to much more different syntax, like typealias or even ‘alias’.

As far as extends/implements go, I plan to keep it the same for simple class aliases as there is utility there and the RFC already covers this.

— Rob

On Sep 7, 2024, at 9:28 AM, Rob Landers <rob@bottled.codes> wrote:
Keep in mind there is already single-class aliasing with well-known behavior for both local and project-wide aliases. Local aliases are done through 'use' statements, and project-wide aliases can be created by using the `class_alias()` function.

Good point.

While I'd prefer to be able to use a typedef/typealias syntax, I certainly would not bikeshed over it.

On Sep 7, 2024, at 9:21 AM, Larry Garfield <larry@garfieldtech.com> wrote:

There is definite utility for this, to create a local alias in a
namespace that can be used throughout the namespace rather than having
to refer to the external namespace in many different places.

Because it quickly can produce nonsensical syntax.

class A {}
class B {}

typealias AB: A|B;

// This is logically nonsensical.
class C extends AB {}

Gotcha. That issue had not occurred to me as my use of type aliases is in other languages that do not have union types. So I was more concerned with this:

namespace A\Really\Long\Namespace {
   class A {}
}
namespace My\Current\Namespace {
   typedef A: \A\Really\Long\Namespace\A {}

   class B extends A {}
}

However, as Rob pointed out there are already ways to accomplish this that I was not thinking of, so my concern for extends and implements is effectively moot.

On Sep 7, 2024, at 9:28 AM, Larry Garfield <larry@garfieldtech.com> wrote:
Methods on typedefs was the sort of "other stuff classes do" that I was trying to avoid getting into right now. :slight_smile: Mainly because I can totally see how it's tempting, but also have no idea what it would mean from a type-theoretic perspective. It would only make sense if we're talking type DEFs, not type ALIASes.

That latter sentence is spot-on.

-Mike

On Sat, Sep 7, 2024, at 15:28, Larry Garfield wrote:

On Fri, Sep 6, 2024, at 7:46 PM, Davey Shafik wrote:

My main struggle with this is readability. As much as I want custom

types (and type aliases is a good chunk of the way there), the main

issue I have is understanding what the valid inputs are:

function foo(Status $string): void { }

How do I know that Status is a) not a class, b) that I can fulfill the

requirement with a string, and/or maybe any object with __toString(),

or maybe it’s ints? Or objects or enums?

Even with file-local aliases (which I would definitely prefer to avoid)

we will most likely rely on developer tooling (e.g. IDEs and static

analyzers) to inform the developer what the right input types are.

I would very much prefer to either go all in with an Enum-like (which

means that we can hang methods on to the value) or we need to

distinguish between type hints for class-likes and type hints for

not-class-likes (*Bar anyone?).

Expanding on type-class-likes: within the type methods, $this->value

would refer to the original value, any operators would follow the

same rules as either the original values type (e.g. $int = 4; $string

= “foo”; $int . $string == “4foo", or call __toString() in all the

normal places for strings if defined).

type Stringable: string|int {

public function __toString(): string

{

return (string) $this->value; // original value

}

// Add Stringable methods here

}.

Methods on typedefs was the sort of “other stuff classes do” that I was trying to avoid getting into right now. :slight_smile: Mainly because I can totally see how it’s tempting, but also have no idea what it would mean from a type-theoretic perspective. It would only make sense if we’re talking type DEFs, not type ALIASes. I’m not against that, and it could be fun to try and think through the type theoretical implications, but I don’t think that’s what Rob was going for so I didn’t want to take that side quest just yet. (Though if he’s OK with it, I’m OK with it.)

To be fair, I should probably mention that I’ve already explored it some (which I alluded to in another thread a couple of weeks ago). :wink: So… I guess it is ‘on-topic’ for no other reason than it is interesting.

Here are my notes thus far:

Sugary Generics

Since we could attach behaviors (at least at the engine level) we could use this to implement generics. Imagine we have a Box and want to instantiate a Box<int|float>. To do this, when we define a Box, we actually define an alias internally.

This is what the definition of our generic box class in PHP might look like:

class Box { public T $var; }

It would get compiled to something like this (though it would probably be invalid php, it perhaps illustrates my meaning):

class Box {

public BoxT $var;

public function __construct(private alias BoxT) {}
}

Then, to instantiate a Box<int|float >, the engine compiles the constructor, passing the types as arguments (still probably not ever valid php) and stealing a bit from python’s “self”:

$box = new Box<int|float>; // compiles to $box = new Box(alias int|float);

The beauty of this is that it automatically becomes an error if you forget the type argument (and if we made it the last argument, would allow setting default values for BC reasons like Collection<T = mixed>)

Even constraints on T could be expressed similarly:

class Box<T: int|float> { public T $var; }

which may get compiled into this not ever valid php:

class Box {

public BoxT $var;

private alias BoxTConstraint => int|float;
public function __construct(private alias BoxT) {

if (BoxT is not a BoxTConstraint) throw new RunTimeException();

}

Basically, it just needs to check that the BoxT alias is of the right type during construction, essentially making generics just a sugary layer over aliases.

Ambitious Type System Replacement

I’ve also explored it in the case of types, in general, by replacing the entire type system with this way of representing types (where a special class represents a real type of value and its behavior). This would allow for defining casting rules, operators, passing types as values (for pattern matching), etc on types themselves. I have no idea what that would look like “at scale”, but it is interesting to think about because primitive types would have the same way of working as classes and everything else. It would also separate types from their implementation—whether we want to expose this to user-land is a different story.

I suspect this could be a ‘technical-only’ change and not affect user-land at all. zvals would probably get a lot simpler though…

I generally stop myself from thinking too much about it, because while interesting, it is a TON of work. Not that I’m afraid of doing that work, I just don’t want to do it by myself. So, I’m cautiously optimistic as a >=9.0 type thing and finding the right people/support. I have no idea how to do that, but I can at least try.

Constraints

Another exploration is that it would potentially allow for setting some constraints on raw values:

alias EmailAddress => string {

if (!is_valid_email($value)) throw new RuntimeException(‘not a valid email address’);
}

However, I think there is a better solution for that (classes/structs/records/etc)… except for

Typed Literals

This could easily allow typed literals for value types. After all, a typed literal can be expressed as an alias over a type with a constraint.

The Hammer and Screw Problem

There are probably other use-cases by making the type-system more ‘class-like’, but I will say that PHP’s class-system is quite robust—if not as robust as its arrays—and should probably be relied on more. That being said, I’ve been working through this for a bit now, and it seems that I might be wielding this as a hammer and seeing nails everywhere, even when they are screws. So, while interesting, in many cases, it probably isn’t the best way to do things.

— Rob

On Sep 7, 2024, at 06:28, Larry Garfield <larry@garfieldtech.com> wrote:

On Fri, Sep 6, 2024, at 7:46 PM, Davey Shafik wrote:

My main struggle with this is readability. As much as I want custom
types (and type aliases is a good chunk of the way there), the main
issue I have is understanding what the valid inputs are:

<snip>

Methods on typedefs was the sort of "other stuff classes do" that I was trying to avoid getting into right now. :slight_smile: Mainly because I can totally see how it's tempting, but also have no idea what it would mean from a type-theoretic perspective. It would only make sense if we're talking type DEFs, not type ALIASes. I'm not against that, and it could be fun to try and think through the type theoretical implications, but I don't think that's what Rob was going for so I didn't want to take that side quest just yet. (Though if he's OK with it, I'm OK with it.)

I 100% agree, but I think type DEFs are something we need to consider before implementing ALIASes so that we don’t block one with the other.

So, with that in mind… I’d also like to open up the ability for Enums
to be fulfilled by the backed value, that is:

This is

1. Off topic for type aliases.
2. Has been discussed numerous times before. Enums are not equivalent to their backing value. The backing value is a standardized-serialization value. Nothing more. A string-backed enum is not a string, and a string is not a string-backed enum. Trying to use an enum as transparently equivalent to its backing value is a categorical error and belies a misunderstanding of what Enums are.

1. It’s on-topic insofar as it’s a potential option for a type hint, and therefore could cause confusion
2. I _do_ understand this, but I also see that what will happen is a million instances of:

typealias StatusValue: Status|string;

function (StatusValue $status): void {
     $status = is_string($status) ? Status::tryFrom($status) : $status;
}

Possibly with a try/catch around it too if you want a custom exception to be thrown.

And yes, I know you can just do this today:

function (Status|string $status): void {
     $status = is_string($status) ? Status::tryFrom($status) : $status;
}

In reality, if we have type DEFs, we’ll probably see a typedef for every backed enum that encapsulates this behavior, so why not just allow it on the enum itself?

My point is that if you talk about type DEFs, you now have this feature where you can input one type and get something that encapsulates it, and it seems weird that enums would LOOK similar In type hint usage and function differently.

Again, I’m mostly concerned about the cognitive overhead of type ALIASes AND type DEFs if they indistinguishable from classes and enums when looking at a function signature and trying to call it without the assistant of advanced auto-complete. At the very least,
It should be clear that there is somewhere else I have to look to understand what the possible inputs are, and would like to again point to the possibility of distinguishing the use of type ALIASes (and/or DEFs) in type hints:

public *StatusValue $status;

function (*StatusValue $status): *StatusValue { }

etc.

- Davey

On 7 September 2024 17:23:13 BST, Davey Shafik <me@daveyshafik.com> wrote:

My point is that if you talk about type DEFs, you now have this feature where you can input one type and get something that encapsulates it, and it seems weird that enums would LOOK similar In type hint usage and function differently.

Personally, I would prefer to go the other way: make typedefs, like enums, something you explicitly construct / cast to, rather than something that implicitly coerces any compatible value.

Like enums, I would want to use typedefs to prevent accidental mixing of values (e.g. a name where a reference number was expected, or a size in pixels where a size in centimetres was expected). That use is compromised if every scalar value is silently accepted for any matching typedef.

Regards,
Rowan Tommins
[IMSoP]

On Sat, Sep 7, 2024, at 12:07 PM, Rowan Tommins [IMSoP] wrote:

On 7 September 2024 17:23:13 BST, Davey Shafik <me@daveyshafik.com> wrote:

My point is that if you talk about type DEFs, you now have this feature where you can input one type and get something that encapsulates it, and it seems weird that enums would LOOK similar In type hint usage and function differently.

Personally, I would prefer to go the other way: make typedefs, like
enums, something you explicitly construct / cast to, rather than
something that implicitly coerces any compatible value.

Like enums, I would want to use typedefs to prevent accidental mixing
of values (e.g. a name where a reference number was expected, or a size
in pixels where a size in centimetres was expected). That use is
compromised if every scalar value is silently accepted for any matching
typedef.

Regards,
Rowan Tommins
[IMSoP]

There's a couple of different use cases floating around close to each other here.

One is a type *alias*, which is just "a way to type less."

The other is a type *def*, which creates a new for-reals type that you can check at runtime.

They are closely related, but serve different purposes. While an alias could make sense file-local or app-wide, in practice a def only makes sense app-wide.

Whether we want to have one or the other or both is a subjective question. Personally I'd be fine with both, as I see them serving different purposes.

eg:

typealias Foo: Bar|Baz;

Foo is now a compile time copy-paste for Bar|Baz, meaning this is totally valid:

class A {
  public Foo $a;
}

class B {
  public Bar|Baz $a;
}

The other direction is:

typedef UserId: int;

UserID is now an object that consists of just an int, but can be type checked against. What's unclear is if you can do other int-y things to them (add, subtract, etc.), or if it's really just a shorthand for

readonly class UserId {
  public function __construct(public int $value) {}
}

I could see an argument for either. If we had operator overloads, I would absolutely go for the latter; make all of those other int-y things opt-in. Once we get pattern matching, as noted a few months ago, it could be quite powerful to allow patterns as a validation on a typedef of that sort.

typedef UserId: int is >0;

Though that opens up all kinds of interesting questions about a typedef based on another typedef, if that's a form of inheritance or not, etc. Again, I'm not sure if Rob wants to go there or not, but it's a place my brain has gone before. :slight_smile:

We may want to focus just on aliases for now, but design them in such a way that they do not cause an issue for typedefs in the future. (Eg, using the `typealias` keyword instead of just `type`.)

--Larry Garfield

On Sep 7, 2024, at 4:36 PM, Larry Garfield <larry@garfieldtech.com> wrote:

The other direction is:

typedef UserId: int;

UserID is now an object that consists of just an int, but can be type checked against. What's unclear is if you can do other int-y things to them (add, subtract, etc.), or if it's really just a shorthand for

Referencing prior art (e.g. Go) PHP could allow int literals — e.g. `1`, `47`, etc. — to be passed to typedefs derived from ints but require int variables to be typecast to the required type. Same for string literals.

In Go you cannot add or subtract on a typedef without casting to the underlying type. I would definitely prefer that to be relaxed, but only if it is relaxed via an explicit opt-in, e.g. something maybe like this:

typedef UserId: int operations: +, -, *, /;
typedef UserName: string operations: .;

Or less cryptic:

typedef UserId: int operations: add, subtract, multiply, divide;
typedef UserName: string operations: concat;

Going with the named operations would allow other operations to be opt-in in the future, but would call into question defining operations as a first-class language element.

readonly class UserId {
public function __construct(public int $value) {}
}

I could see an argument for either.

Typedefs enable a developer to write more robust code that they currently cannot do, whereas typealiases are really just syntax sugar, albeit sugar that probably tastes really good.

Said more explicitly, I would prefer both but if it is has to be only one to start, I would prefer starting with typedefs.

Though that opens up all kinds of interesting questions about a typedef based on another typedef, if that's a form of inheritance or not, etc. Again, I'm not sure if Rob wants to go there or not, but it's a place my brain has gone before. :slight_smile:

Given that a typedef can always just reference the original type(s) rather than basing a typedef on another typedef I would err on the conservative side and say initially no typedef of a typedef.

The current downside would be that a complex union typedef defined in one namespace could not easily be referred to in another namespace without repeating the union typedef. Whether that would become a frequent problem remains to be seen so it could wait for a future RFC if so.

Another limit would to the workaround would be if a typedef is defined as private for a namespace. This as namespace-private is not currently possible we could cross that typedef-on-a-typedef bridge on a future day if namespace-private ever becomes possible. #jmtcw

We may want to focus just on aliases for now, but design them in such a way that they do not cause an issue for typedefs in the future. (Eg, using the `typealias` keyword instead of just `type`.)

Another option is to use a different syntax:

type MyNewType: Foo
type MyAlias = Foo

Not arguing for or against any specific syntax, just pointing out that there are other potential options.

-Mike

assert( Metres(2) + Metres(1) === Metres(3) ); // most obvious assert( Metres(2) + 1 === Metres(3) ); // seems pretty clear

···

On 09/09/2024 19:41, Mike Schinkel wrote:

Referencing prior art (e.g. Go) PHP could allow int literals — e.g. `1`, `47`, etc. — to be passed to typedefs derived from ints but require int variables to be typecast to the required type. Same for string literals. 

That’s an interesting compromise, worth considering.

In Go you cannot add or subtract on a typedef without casting to the 
underlying type.  I would definitely prefer that to be relaxed, but only
 if it is relaxed via an explicit opt-in, e.g. something maybe like 
this:

typedef UserId: int operations: +, -, *, /;
typedef UserName: string operations: .;

I think this would stray into some of the same complexity as operator overloads on objects, in terms of the types and values allowed. For instance:

typedef Metres: int;

$_GET[‘input’] = ‘1’;
assert( Metres(2) + $_GET[‘input’] === Metres(3) ); // might be more controversial

typedef Feet: int;
assert( Metres(2) + Feet(1) === Metres(3) ); // almost certainly a bad idea

Not unsolvable, but probably enough scope for nuance and debate that it should be left well into Future Scope.

type MyNewType: Foo
type MyAlias = Foo

I know this was only an example, but as a general point, I think we should avoid concise but cryptic differences like this. PHP is generally keyword-heavy, rather than punctuation-heavy, and I think that’s a valid style which we should keep to.

-- 
Rowan Tommins
[IMSoP]

I tend to agree that allowing operations may be too much for an initial scope given that it is unlike anything else in the current language and with no other languages offering an equivalent AFAIK.

I would however make the distinction that it is unlike operator overloading because the big concern was what constituted an operation for any given type could be too subjective. In your example of Metres it is pretty obvious, but not at all obvious for a User, for example. (BTW, thank you for not calling out my nonsensical example of operations on a UserId; when I wrote that I clear was not thinking about if they were relevant, doh!)

However give the suggestion regarding operations with a typedef, the only operations that I suggested would be valid would be the ones already defined on the underlying type, (when I mentioned other operations I was thinking of methods — see my the following example with round — not operators so that is not the same as operator overload.) For example:

/**

  • Currency is an int so for example in USD 1
  • unit of currency not a dollar but a cent.
    /
    typedef Currency: int operations: +,-,
    ,/,round;
    function CalcTotal(Currency $subTotal, Currency $shipping, float $tax):Currency {
    return round($subTotal*(1+$tax/100),0) + $shipping;
    }

typedef Metres: int;

assert( Metres(2) + Metres(1) === Metres(3) ); // most obvious
assert( Metres(2) + 1 === Metres(3) ); // seems pretty clear

Both of those are in line with what I was suggesting.

$_GET[‘input’] = ‘1’;
assert( Metres(2) + $_GET[‘input’] === Metres(3) ); // might be more controversial

I would not consider this appropriate as it has two levels of conversion and could thus end up with unintended edge cases. To do the above I think you would have to either convert or typecast:

assert( Metres(2) + intval($_GET[‘input’]) === Metres(3) );
assert( Metres(2) + (int)$_GET[‘input’] === Metres(3) );

typedef Feet: int;
assert( Metres(2) + Feet(1) === Metres(3) ); // almost certainly a bad idea

This would be operator overloading where knowledge of the conversion between meters and feet would be required, and that is not in any way in scope with what I was suggesting.

As an aside, I am against userland operator overloading as I have seen in other languages that operator overloading gets abused and results in code that is a nightmare to maintain. OTOH, I would support operator overloading in specific cases, e.g. a Measurement class in PHP core could allow adding meters to feet, assuming such a proposal were made and all other aspects of the RFC were of the nature to be voted in.

To reiterate on typedefs, what I was suggesting was that if an operation was explicitly allowed — e.g. + — then anything that would work with the underlying type — such as adding an int 1 would work without typecasting and yet still result in the typedef type, e.g. Meters(2) + 1 results in a value of type Meters. (note that I corrected your spelling of ‘Meters’ here. :wink:

But I agree, this is probably a bridge too far for a first RFC for typedefs.

type MyNewType: Foo
type MyAlias = Foo

I know this was only an example, but as a general point, I think we should avoid concise but cryptic differences like this. PHP is generally keyword-heavy, rather than punctuation-heavy, and I think that’s a valid style which we should keep to.

Here, I also tend to agree WRT PHP. Was just pointing out for sake of laying out other options that were implied not to exist.

-Mike

···

On 09/09/2024 19:41, Mike Schinkel wrote:

In Go you cannot add or subtract on a typedef without casting to the 
underlying type.  I would definitely prefer that to be relaxed, but only
 if it is relaxed via an explicit opt-in, e.g. something maybe like 
this:

typedef UserId: int operations: +, -, *, /;
typedef UserName: string operations: .;

I think this would stray into some of the same complexity as operator overloads on objects, in terms of the types and values allowed. For instance: