[PHP-DEV] Proposal: AS assertions

Hello internals,

I've been thinking about this as an RFC for awhile, but with generics
being far off (if at all), I'd like to propose a useful idea: reusing
the AS keyword in a different context.

Example:

$x = $attributeReflection->newInstance() as MyAttribute;

This would essentially perform the following code:

assert(($x = $attributeReflection->newInstance()) instanceof MyAttribute);

but would work even if assertions are disabled, and would provide some
sanity when working with mixed return types, or even dealing with
interfaces where you want to be sure you are dealing with a concrete
type:

class Query implements QueryInterface {}

function getQuery(string $sql): QueryInterface {}

$x = getQuery("select 1 = 1") as Query;

which is more like:

assert(($x = getQuery("select 1 = 1")) instanceof Query);

It'd also be nice to have a non-throwing version where we simply
specify that the type is nullable:

$x = $attributeReflection->newInstance() as ?MyAttribute;
if ($x === null) // do something since the attribute isn't MyAttribute

which is more like:

try {
  assert(($x = $attributeReflection->newInstance()) instanceof MyAttribute);
} catch {
  $x = null
}

Or a more complex type:

$x = $attributeReflection->newInstance() as
PretttyAttribute|(UglyAttribute&UtilityAttribute);

Essentially, by using "as", you can be 100% sure that the type is the
expected type signature, null (if the type signature includes null),
or an error to be thrown.

Note that this isn't casting from one type to another, but asserting
that this type is the type you expect. It'd significantly help with
static analysis, IDE code completion, etc.

What do you think?

Robert Landers
Software Engineer
Utrecht NL

I believe you answered your own question here. The proposal seems far simpler and reaches 100% of PHP projects as opposed to the ones that either opt to use psalm or opt to use azjezz/psl.

···

Marco Deleu

Hi Robert

On Tue, Mar 19, 2024 at 5:24 PM Robert Landers <landers.robert@gmail.com> wrote:

I've been thinking about this as an RFC for awhile, but with generics
being far off (if at all), I'd like to propose a useful idea: reusing
the AS keyword in a different context.

Example:

$x = $attributeReflection->newInstance() as MyAttribute;

This would essentially perform the following code:

assert(($x = $attributeReflection->newInstance()) instanceof MyAttribute);

See PHP: rfc:pattern-matching. I
believe this idea would combine nicely with pattern matching. It has
many more uses there than just simple class type matching, and could
even be used for things like destructuring.

Ilija

Eh, kinda: you’d need to check how Psl\Type\TypeInterface recursively validates types and throws meaningful errors.

Having that in the engine, given its structure, is a massive BC surface that is best kept as a composer dependency that can move separately.
I hardly see that working in a language-level RFC, with the speed at which the language can do BC incompatible changes.

See https://github.com/azjezz/psl/blob/5f0aeacb708a33d5b2d53a832736c7767a99b215/src/Psl/Type/TypeInterface.php#L21-L35
See https://github.com/azjezz/psl/blob/5f0aeacb708a33d5b2d53a832736c7767a99b215/src/Psl/Type/Exception/CoercionException.php#L49
See https://github.com/azjezz/psl/blob/5f0aeacb708a33d5b2d53a832736c7767a99b215/src/Psl/Type/Exception/Exception.php#L22
See https://github.com/azjezz/psl/blob/5f0aeacb708a33d5b2d53a832736c7767a99b215/src/Psl/Type/Exception/TypeTrace.php

That stuff is all but figured out, even in userland :slight_smile:

Also worth mentioning:

https://github.com/CuyZ/Valinor/blob/37993b64a6eb04dc0aee79e03f2ddb4f86ff9c3a/src/Mapper/TreeMapper.php#L23-L25
https://github.com/CuyZ/Valinor/blob/37993b64a6eb04dc0aee79e03f2ddb4f86ff9c3a/src/Mapper/MappingError.php#L13 and the whole rabbit hole behind that

···

Marco Pivetta

https://mastodon.social/@ocramius

https://ocramius.github.io/

Hey Robert,

Hello internals,

I’ve been thinking about this as an RFC for awhile, but with generics
being far off (if at all), I’d like to propose a useful idea: reusing
the AS keyword in a different context.

Example:

$x = $attributeReflection->newInstance() as MyAttribute;

This would essentially perform the following code:

assert(($x = $attributeReflection->newInstance()) instanceof MyAttribute);

but would work even if assertions are disabled, and would provide some
sanity when working with mixed return types, or even dealing with
interfaces where you want to be sure you are dealing with a concrete
type:

class Query implements QueryInterface {}

function getQuery(string $sql): QueryInterface {}

$x = getQuery(“select 1 = 1”) as Query;

which is more like:

assert(($x = getQuery(“select 1 = 1”)) instanceof Query);

It’d also be nice to have a non-throwing version where we simply
specify that the type is nullable:

$x = $attributeReflection->newInstance() as ?MyAttribute;
if ($x === null) // do something since the attribute isn’t MyAttribute

which is more like:

try {
assert(($x = $attributeReflection->newInstance()) instanceof MyAttribute);
} catch {
$x = null
}

Or a more complex type:

$x = $attributeReflection->newInstance() as
PretttyAttribute>(UglyAttribute&UtilityAttribute);

Essentially, by using “as”, you can be 100% sure that the type is the
expected type signature, null (if the type signature includes null),
or an error to be thrown.

Note that this isn’t casting from one type to another, but asserting
that this type is the type you expect. It’d significantly help with
static analysis, IDE code completion, etc.

What do you think?

Robert Landers
Software Engineer
Utrecht NL

What’s the advantage of a language construct over the following?

/**
* @template T of object

* @psalm-assert T $value
* @param class-string<T> $type
*/

function as(mixed $value, string $type): mixed
{
if (! $value instanceof $type) { throw SomeKindOfException::forMismatchingRequirements($value, $type); }

return $value;
}

echo as(myExpression(), MyType::class)->methodOfMyType();

See https://3v4l.org/iQPok
See https://phpstan.org/r/708912d3-64e2-46f0-9f9e-467921a6489a
See https://psalm.dev/r/7f30d63865

Note that azjezz/psl provides a very complete toolkit around this kind of tooling: https://github.com/azjezz/psl/tree/5f0aeacb708a33d5b2d53a832736c7767a99b215/src/Psl/Type

One note: if what you are going for is what azjezz/psl, be aware that exception / error tracing design needs special attention here: it’s not as simple as it looks!

···

Marco Pivetta

https://mastodon.social/@ocramius

https://ocramius.github.io/

1 Like

Hi Marco

On Tue, Mar 19, 2024 at 7:04 PM Marco Aurélio Deleu <deleugyn@gmail.com> wrote:

> On 19 Mar 2024, at 14:51, Ilija Tovilo <tovilo.ilija@gmail.com> wrote:
>
> ï»żHi Robert
>
>> On Tue, Mar 19, 2024 at 5:24 PM Robert Landers <landers.robert@gmail.com> wrote:
>>
> See PHP: rfc:pattern-matching. I
> believe this idea would combine nicely with pattern matching. It has
> many more uses there than just simple class type matching, and could
> even be used for things like destructuring.

That looks like a PHP dream. Has there been any work regarding that?

The implementation is mostly complete (it might slightly diverge from
the current specification. Bob has called for a different
implementation approach that might be more complex but potentially
easier to optimize, I'll have to play around with it. There are also
still some design decisions that we aren't completely sure about. For
now, Larry and I are just trying to get property hooks over the finish
line.

Ilija

Marco Deleu

On 19 Mar 2024, at 14:51, Ilija Tovilo <tovilo.ilija@gmail.com> wrote:

Hi Robert

On Tue, Mar 19, 2024 at 5:24 PM Robert Landers <landers.robert@gmail.com> wrote:

I've been thinking about this as an RFC for awhile, but with generics
being far off (if at all), I'd like to propose a useful idea: reusing
the AS keyword in a different context.

Example:

$x = $attributeReflection->newInstance() as MyAttribute;

This would essentially perform the following code:

assert(($x = $attributeReflection->newInstance()) instanceof MyAttribute);

See PHP: rfc:pattern-matching. I
believe this idea would combine nicely with pattern matching. It has
many more uses there than just simple class type matching, and could
even be used for things like destructuring.

Ilija

That looks like a PHP dream. Has there been any work regarding that?

On 19/03/2024 16:24, Robert Landers wrote:

$x = $attributeReflection->newInstance() as ?MyAttribute;
if ($x === null) // do something since the attribute isn't MyAttribute

I think reusing nullability for this would be a mistake - ideally, the right-hand side should allow any type, so "$foo as ?Foo" should mean the same as "$foo as Foo|null".

A better alternative might be to specify a default when the type didn't match:

$x = $attributeReflection->newInstance() as ?MyAttribute else null;
if ($x === null) // do something since the attribute isn't MyAttribute

Which then also allows you to skip the if statement completely:

$x = $attributeReflection->newInstance() as MyAttribute else MyAttribute::createDefault();

That then looks a lot like a limited-use version of syntax for catching an exception inline, which would be nice as a general feature (but I think maybe hard to implement?)

$x = somethingThatThrows() catch $someDefaultValue;

As well pattern matching, which Ilija mentioned, another adjacent feature is a richer set of casting operators. Currently, we can assert that something is an int; or we can force it to be an int; but we can't easily say "make this an int if safe, but throw otherwise" or "make this an int if safe, but substitute null/$someValue otherwise".

I've been considering how we can improve that for a while, but not settled on a firm proposal - there's a lot of different versions we *could* support, so choosing a minimal set is hard.

Regards,

--
Rowan Tommins
[IMSoP]

Hi Rowan

On Tue, Mar 19, 2024 at 8:39 PM Rowan Tommins [IMSoP]
<imsop.php@rwec.co.uk> wrote:

As well pattern matching, which Ilija mentioned, another adjacent feature is a richer set of casting operators. Currently, we can assert that something is an int; or we can force it to be an int; but we can't easily say "make this an int if safe, but throw otherwise" or "make this an int if safe, but substitute null/$someValue otherwise".

I've been considering how we can improve that for a while, but not settled on a firm proposal - there's a lot of different versions we *could* support, so choosing a minimal set is hard.

I've thought about this in the context of pattern matching a while
back. I was thinking about something like `$x is ~int`, where the
pattern match is successful iff `$x` is coercible to `int` without
loss of information. Given that patterns may be nested, `array<~int>`
could check that all elements of an array are coercible to `int`. The
same could work for literal patterns, e.g. `~5`, where `5`, `5.0` and
`'5'` are all accepted.

This can potentially be combined with the variable binding pattern,
`$var @ pattern`. The syntax looks a bit confusing at first, but it
basically makes sure that the matched value conforms to `pattern`, and
then binds it to `$var`. Hence, something like `$foo as Foo { $bar @
~int }` would 1. make sure `$foo` is an instance of `Foo`, 2. make
sure `$foo->bar` is coercible to `int`, and then assigned the coerced
value to `$bar`. (It gets more complicated, because the assignment
must be delayed until the entire pattern matches.) If the pattern
matching fails at any point, it throws.

This is just an idea, neither the `as` operator nor the `~` pattern
have been implemented. I don't know whether they are feasible.

Anyway, we're probably going off-topic. :slight_smile:

Ilija

On Tue, Mar 19, 2024 at 10:06 PM Rowan Tommins [IMSoP]
<imsop.php@rwec.co.uk> wrote:

On 19/03/2024 16:24, Robert Landers wrote:

$x = $attributeReflection->newInstance() as ?MyAttribute;
if ($x === null) // do something since the attribute isn't MyAttribute

I think reusing nullability for this would be a mistake - ideally, the right-hand side should allow any type, so "$foo as ?Foo" should mean the same as "$foo as Foo|null".

A better alternative might be to specify a default when the type didn't match:

$x = $attributeReflection->newInstance() as ?MyAttribute else null;
if ($x === null) // do something since the attribute isn't MyAttribute

At that point, you are just reinventing already existing things. If
you wanted to do something like that with my proposal:

$x = $attributeReflection->newInstance() as ?MyAttribute ??
MyAttribute::createDefault();

Robert Landers
Software Engineer
Utrecht NL

On Wed, Mar 20, 2024 at 1:47 PM Robert Landers <landers.robert@gmail.com> wrote:

On Tue, Mar 19, 2024 at 10:06 PM Rowan Tommins [IMSoP]
<imsop.php@rwec.co.uk> wrote:
>
> On 19/03/2024 16:24, Robert Landers wrote:
>
> $x = $attributeReflection->newInstance() as ?MyAttribute;
> if ($x === null) // do something since the attribute isn't MyAttribute
>
>
> I think reusing nullability for this would be a mistake - ideally, the right-hand side should allow any type, so "$foo as ?Foo" should mean the same as "$foo as Foo|null".
>
>
> A better alternative might be to specify a default when the type didn't match:
>
> $x = $attributeReflection->newInstance() as ?MyAttribute else null;
> if ($x === null) // do something since the attribute isn't MyAttribute

At that point, you are just reinventing already existing things. If
you wanted to do something like that with my proposal:

$x = $attributeReflection->newInstance() as ?MyAttribute ??
MyAttribute::createDefault();

Robert Landers
Software Engineer
Utrecht NL

Oh and there isn't any difference between:

$x as ?Type

or

$x as Type|null

The codebase I work with prefers ? over |null, but they are the same.

On 20 March 2024 12:51:15 GMT, Robert Landers landers.robert@gmail.com wrote:

Oh and there isn’t any difference between:

$x as ?Type

or

$x as Type|null

I’m not sure if I’ve misunderstood your example, or you’ve misunderstood mine.

I’m saying that this should be an error, because the value is neither an instance of Foo nor null:

$a = 42;
$b = $a as Foo|null;

Your earlier example implies that would make $b equal null, which feels wrong to me, because it means it wouldn’t match this:

$a = 42;
$b = $a as Foo|Bar;

If we want a short-hand for “set to null on error” that should be separate from the syntax for a nullable type.

Regards,
Rowan Tommins
[IMSoP]

On Wed, Mar 20, 2024 at 8:30 PM Rowan Tommins [IMSoP]
<imsop.php@rwec.co.uk> wrote:

On 20 March 2024 12:51:15 GMT, Robert Landers <landers.robert@gmail.com> wrote:

>Oh and there isn't any difference between:
>
>$x as ?Type
>
>or
>
>$x as Type|null

I'm not sure if I've misunderstood your example, or you've misunderstood mine.

I'm saying that this should be an error, because the value is neither an instance of Foo nor null:

$a = 42;
$b = $a as Foo|null;

Your earlier example implies that would make $b equal null, which feels wrong to me, because it means it wouldn't match this:

$a = 42;
$b = $a as Foo|Bar;

If we want a short-hand for "set to null on error" that should be separate from the syntax for a nullable type.

Regards,
Rowan Tommins
[IMSoP]

Interesting. I'm not sure there's a better way to say "set to null on
error" since it would be barely sensical to give a nullable type on
the right hand side anyway; so we might as well use it. In other
words, I can't think of a case where you'd actually want a Type|null
and you wouldn't have to check for null anyway.

If you think about it, in this proposal, you could use it in a match:

// $a is TypeA|TypeB|null

match (true) {
  $a as ?TypeA => 'a',
  $a as ?TypeB => 'b',
  $a === null => 'null',
}

No matter what, you're going to have to check for null if you want to
handle all cases and throwing an error when you ask for a nullable
type would render a ton of utility moot (i.e., this match statement
wouldn't work). It's probably better to say, "I want what is on the
right-hand side of `as` or an error." Including `null` in that type
seems to be that you would get null if no other type matches, since
any variable can be `null`.

Robert Landers
Software Engineer
Utrecht NL

On 20/03/2024 23:05, Robert Landers wrote:

In other
words, I can't think of a case where you'd actually want a Type|null
and you wouldn't have to check for null anyway.

It's not about having to check for null; it's about being able to distinguish between "a null value, which was one of the expected types" and "a value of an unexpected type".

That's a distinction which is made everywhere else in the language: parameter types, return types, property types, will all throw an error if you pass a Foo when a ?Bar was expected, they won't silently coerce it to null.

If you think about it, in this proposal, you could use it in a match:

// $a is TypeA|TypeB|null

match (true) {
  $a as ?TypeA => 'a',
  $a as ?TypeB => 'b',
  $a === null => 'null',
}

That won't work, because match performs a strict comparison, and the as expression won't return a boolean true. You would have to do this:

match (true) {
  (bool)($a as ?TypeA) => 'a',
  (bool)($a as ?TypeB) => 'b',
  $a === null => 'null',
}
Or this:

match (true) {
  ($a as ?TypeA) !== null => 'a',
  ($a as ?TypeB) !== null => 'b',
  $a === null => 'null',
}

Neither of which is particularly readable. What you're really looking for in that case is an "is" operator:
match (true) {
  $a is TypeA => 'a',
  $a is TypeB => 'b',
  $a === null => 'null',
}
Which in the draft pattern matching RFC Ilija linked to can be abbreviated to:

match ($a) is {
  TypeA => 'a',
  TypeB => 'b',
  null => 'null',
}

Of course, in simple cases, you can use "instanceof" in place of "is" already:

match (true) {
  $a instanceof TypeA => 'a',
  $a instanceof TypeB => 'b',
  $a === null => 'null',
}

Including `null` in that type
seems to be that you would get null if no other type matches, since
any variable can be `null`.

I can't think of any sense in which "any variable can be null" that is not true of any other type you might put in the union. We could interpret Foo|false as meaning "use false as the fallback"; or Foo|int as "use zero as the fallback"; but I don't think that would be sensible.
In other words, the "or null on failure" part is an option to the "as" expression, it's not part of the type you're checking against. If we only wanted to support "null on failure", we could have a different keyword, like "?as":

$bar = new Bar;
$bar as ?Foo; // Error
$bar ?as Foo; // null (as fallback)

$null = null;
$null as ?Foo; // null (because it's an accepted value)
$null ?as Foo; // null (as fallback)

A similar suggestion was made in a previous discussion around nullable casts - to distinguish between (?int)$foo as "cast to nullable int" and (int?)$foo as "cast to int, with null on error".

Note however that combining ?as with ?? is not enough to support "chosen value on failure":

$bar = new Bar;
$bar ?as ?Foo ?? Foo::createDefault(); // creates default object

$null = null;
$null ?as ?Foo ?? Foo::createDefault(); // also creates default object, even though null is an expected value

That's why my earlier suggestion was to specify the fallback explicitly:

$bar = new Bar;
$bar as ?Foo else null; // null
$bar as ?Foo else Foo::createDefault(); // default object

$null = null;
$nulll as ?Foo else null; // null
$null as ?Foo else Foo::createDefault(); // also null, because it's an accepted value, so the fallback is not evaluated

Probably, it should then be an error if the fallback value doesn't meet the constraint:

$bar = new Bar;
$bar as Foo else null; // error: fallback value null is not of type Foo
$bar as ?Foo else 42; // error: fallback value 42 is not of type ?Foo

Regards,
--
Rowan Tommins
[IMSoP]

On Thu, Mar 21, 2024 at 12:45 PM Rowan Tommins [IMSoP]
<imsop.php@rwec.co.uk> wrote:

On 20/03/2024 23:05, Robert Landers wrote:

In other
words, I can't think of a case where you'd actually want a Type|null
and you wouldn't have to check for null anyway.

It's not about having to check for null; it's about being able to distinguish between "a null value, which was one of the expected types" and "a value of an unexpected type".

That's a distinction which is made everywhere else in the language: parameter types, return types, property types, will all throw an error if you pass a Foo when a ?Bar was expected, they won't silently coerce it to null.

If you think about it, in this proposal, you could use it in a match:

// $a is TypeA|TypeB|null

match (true) {
  $a as ?TypeA => 'a',
  $a as ?TypeB => 'b',
  $a === null => 'null',
}

That won't work, because match performs a strict comparison, and the as expression won't return a boolean true. You would have to do this:

match (true) {
  (bool)($a as ?TypeA) => 'a',
  (bool)($a as ?TypeB) => 'b',
  $a === null => 'null',
}

Or this:

match (true) {
  ($a as ?TypeA) !== null => 'a',
  ($a as ?TypeB) !== null => 'b',
  $a === null => 'null',
}

Neither of which is particularly readable. What you're really looking for in that case is an "is" operator:

match (true) {
  $a is TypeA => 'a',
  $a is TypeB => 'b',
  $a === null => 'null',
}

Which in the draft pattern matching RFC Ilija linked to can be abbreviated to:

match ($a) is {
  TypeA => 'a',
  TypeB => 'b',
  null => 'null',
}

Of course, in simple cases, you can use "instanceof" in place of "is" already:

match (true) {
  $a instanceof TypeA => 'a',
  $a instanceof TypeB => 'b',
  $a === null => 'null',
}

Including `null` in that type
seems to be that you would get null if no other type matches, since
any variable can be `null`.

I can't think of any sense in which "any variable can be null" that is not true of any other type you might put in the union. We could interpret Foo|false as meaning "use false as the fallback"; or Foo|int as "use zero as the fallback"; but I don't think that would be sensible.

In other words, the "or null on failure" part is an option to the "as" expression, it's not part of the type you're checking against. If we only wanted to support "null on failure", we could have a different keyword, like "?as":

$bar = new Bar;
$bar as ?Foo; // Error
$bar ?as Foo; // null (as fallback)

$null = null;
$null as ?Foo; // null (because it's an accepted value)
$null ?as Foo; // null (as fallback)

A similar suggestion was made in a previous discussion around nullable casts - to distinguish between (?int)$foo as "cast to nullable int" and (int?)$foo as "cast to int, with null on error".

Note however that combining ?as with ?? is not enough to support "chosen value on failure":

$bar = new Bar;
$bar ?as ?Foo ?? Foo::createDefault(); // creates default object

$null = null;
$null ?as ?Foo ?? Foo::createDefault(); // also creates default object, even though null is an expected value

That's why my earlier suggestion was to specify the fallback explicitly:

$bar = new Bar;
$bar as ?Foo else null; // null
$bar as ?Foo else Foo::createDefault(); // default object

$null = null;
$nulll as ?Foo else null; // null
$null as ?Foo else Foo::createDefault(); // also null, because it's an accepted value, so the fallback is not evaluated

Probably, it should then be an error if the fallback value doesn't meet the constraint:

$bar = new Bar;
$bar as Foo else null; // error: fallback value null is not of type Foo
$bar as ?Foo else 42; // error: fallback value 42 is not of type ?Foo

Regards,
--
Rowan Tommins
[IMSoP]

I don't think you are getting what I am saying.

$a as int|float

would be an int, float, or thrown exception.

$a as int|float|null

would be an int, float, or null.

Robert Landers
Software Engineer
Utrecht NL

On Thu, Mar 21, 2024 at 12:45 PM Rowan Tommins [IMSoP]
<imsop.php@rwec.co.uk> wrote:

On 20/03/2024 23:05, Robert Landers wrote:

In other
words, I can't think of a case where you'd actually want a Type|null
and you wouldn't have to check for null anyway.

It's not about having to check for null; it's about being able to distinguish between "a null value, which was one of the expected types" and "a value of an unexpected type".

That's a distinction which is made everywhere else in the language: parameter types, return types, property types, will all throw an error if you pass a Foo when a ?Bar was expected, they won't silently coerce it to null.

If you think about it, in this proposal, you could use it in a match:

// $a is TypeA|TypeB|null

match (true) {
  $a as ?TypeA => 'a',
  $a as ?TypeB => 'b',
  $a === null => 'null',
}

That won't work, because match performs a strict comparison, and the as expression won't return a boolean true. You would have to do this:

match (true) {
  (bool)($a as ?TypeA) => 'a',
  (bool)($a as ?TypeB) => 'b',
  $a === null => 'null',
}

Or this:

match (true) {
  ($a as ?TypeA) !== null => 'a',
  ($a as ?TypeB) !== null => 'b',
  $a === null => 'null',
}

Neither of which is particularly readable. What you're really looking for in that case is an "is" operator:

match (true) {
  $a is TypeA => 'a',
  $a is TypeB => 'b',
  $a === null => 'null',
}

Which in the draft pattern matching RFC Ilija linked to can be abbreviated to:

match ($a) is {
  TypeA => 'a',
  TypeB => 'b',
  null => 'null',
}

Of course, in simple cases, you can use "instanceof" in place of "is" already:

match (true) {
  $a instanceof TypeA => 'a',
  $a instanceof TypeB => 'b',
  $a === null => 'null',
}

Including `null` in that type
seems to be that you would get null if no other type matches, since
any variable can be `null`.

I can't think of any sense in which "any variable can be null" that is not true of any other type you might put in the union. We could interpret Foo|false as meaning "use false as the fallback"; or Foo|int as "use zero as the fallback"; but I don't think that would be sensible.

In other words, the "or null on failure" part is an option to the "as" expression, it's not part of the type you're checking against. If we only wanted to support "null on failure", we could have a different keyword, like "?as":

$bar = new Bar;
$bar as ?Foo; // Error
$bar ?as Foo; // null (as fallback)

$null = null;
$null as ?Foo; // null (because it's an accepted value)
$null ?as Foo; // null (as fallback)

A similar suggestion was made in a previous discussion around nullable casts - to distinguish between (?int)$foo as "cast to nullable int" and (int?)$foo as "cast to int, with null on error".

Note however that combining ?as with ?? is not enough to support "chosen value on failure":

$bar = new Bar;
$bar ?as ?Foo ?? Foo::createDefault(); // creates default object

$null = null;
$null ?as ?Foo ?? Foo::createDefault(); // also creates default object, even though null is an expected value

That's why my earlier suggestion was to specify the fallback explicitly:

$bar = new Bar;
$bar as ?Foo else null; // null
$bar as ?Foo else Foo::createDefault(); // default object

$null = null;
$nulll as ?Foo else null; // null
$null as ?Foo else Foo::createDefault(); // also null, because it's an accepted value, so the fallback is not evaluated

Probably, it should then be an error if the fallback value doesn't meet the constraint:

$bar = new Bar;
$bar as Foo else null; // error: fallback value null is not of type Foo
$bar as ?Foo else 42; // error: fallback value 42 is not of type ?Foo

Regards,
--
Rowan Tommins
[IMSoP]

Another way of thinking about is:

$x = $a as null

What do you expect $x to be?

On Thu, Mar 21, 2024, at 3:02 PM, Robert Landers wrote:

I don't think you are getting what I am saying.

$a as int|float

would be an int, float, or thrown exception.

$a as int|float|null

would be an int, float, or null.

Robert Landers
Software Engineer
Utrecht NL

Hi Rob. I really do encourage you to read the RFC that Ilija linked to already. What you're proposing is already mostly written (though for performance reasons may be rewritten soon), and the edge cases already largely resolved.

https://wiki.php.net/rfc/pattern-matching

--Larry Garfield

On 21/03/2024 15:02, Robert Landers wrote:

I don't think you are getting what I am saying.

$a as int|float

would be an int, float, or thrown exception.

$a as int|float|null

would be an int, float, or null.

I get what you're saying, but I disagree that it's a good idea.

If $a is 'hello', both of those statements should throw exactly the same error, for exactly the same reason - the input is not compatible with the type you have specified.

Another way of thinking about is:

$x = $a as null

What do you expect $x to be?

The same as $x inside this function:

function foo(null $x) { var_dump($x); }
foo($a);

Which is null if $a is null, and a TypeError if $a is anything else: Online PHP editor | output for 5UR5A

Regards,

--
Rowan Tommins
[IMSoP]

On Thu, Mar 21, 2024 at 7:01 PM Rowan Tommins [IMSoP]
<imsop.php@rwec.co.uk> wrote:

On 21/03/2024 15:02, Robert Landers wrote:

I don't think you are getting what I am saying.

$a as int|float

would be an int, float, or thrown exception.

$a as int|float|null

would be an int, float, or null.

I get what you're saying, but I disagree that it's a good idea.

If $a is 'hello', both of those statements should throw exactly the same error, for exactly the same reason - the input is not compatible with the type you have specified.

Another way of thinking about is:

$x = $a as null

What do you expect $x to be?

The same as $x inside this function:

function foo(null $x) { var_dump($x); }
foo($a);

Which is null if $a is null, and a TypeError if $a is anything else: Online PHP editor | output for 5UR5A

Regards,

--
Rowan Tommins
[IMSoP]

I suppose we are taking this from different viewpoints, yours appears
to be more of a philosophical one, whereas mine is more of a practical
one.

$x = $a as null;

(or any other value, such as true|false) appears to have no practical
purpose in this particular case. This is better checked with `===`, or
even in_array(). Values are easy to check in PHP and there are already
lots of great and simple ways to check a value. Further, reading "$x =
$a as null", as a native English speaker, appears to be the same as
"$x = null".

As I mentioned in the beginning, I see this mostly being used when
dealing with mixed types from built-in/library functions, where you
have no idea what the actual type is, but when you write the code, you
have a reasonable expectation of a set of types and you want to throw
if it is unexpected. Right now, the best way to do that is to simply
set a function signature and pass the mixed type to the function to
have the engine do it for you; or write out a bunch of instanceofs
when that isn't worth it. However, this is cumbersome.

I'd also like to say that I'm not strongly attached to the |null
behavior I'm proposing, but there are better ways to assert a variable
is equal to a value. It makes more sense, from a practical programming
point-of-view, to simply return the value given if none of the types
match.

Robert Landers
Software Engineer
Utrecht NL

On 21/03/2024 19:03, Robert Landers wrote:

I suppose we are taking this from different viewpoints, yours appears
to be more of a philosophical one, whereas mine is more of a practical
one.

My main concern is consistency; which is partly philosophical, but does have practical impact - the same syntax meaning the same thing in different contexts leads to less user confusion and fewer bugs.

But I also think there are real use cases for "error on anything other than either Foo or null" separate from "give me a null for anything other than Foo".

$x = $a as null;

(or any other value, such as true|false) appears to have no practical
purpose in this particular case.

There's plenty of possible pieces of code that have no practical purpose, but that on its own isn't a good reason to make them do something different.

"null" as a standalone type (rather than part of a union) is pretty much always pointless, and was forbidden until PHP 8.2. It's now allowed, partly because there are scenarios involving inheritance where it does actually make sense (e.g. narrowing a return type from Foo|null to null); and probably also because it's easier to allow it than forbid it.

That's not really what we're talking about anyway, though; we're talking about nullable types, or null in a union type, which are much more frequently used.

Further, reading "$x =
$a as null", as a native English speaker, appears to be the same as
"$x = null".

Well, that's a potential problem with the choice of syntax: "$x = $a as int" could easily be mistaken for "cast $a as int", rather than "assert that $a is int".

If you spell out "assert that $a is null", or "assert that $a is int|null", it becomes very surprising for 'hello' to do anything other than fail the assertion.

As I mentioned in the beginning, I see this mostly being used when
dealing with mixed types from built-in/library functions, where you
have no idea what the actual type is, but when you write the code, you
have a reasonable expectation of a set of types and you want to throw
if it is unexpected.

My argument is that you might have a set of expected types which includes null, *and* want to throw for other, unexpected, values. If "|null" is special-cased to mean "default to null", there's no way to do that.

Right now, the best way to do that is to simply
set a function signature and pass the mixed type to the function to
have the engine do it for you

And if you do that, then a value of 'hello' passed to a parameter of type int|null, will throw a TypeError, not give you a null.

As I illustrated in my last e-mail, you can even (since PHP 8.2) have a parameter of type null, and get a TypeError for any other value. That may not be useful, but it's entirely logical.

It makes more sense, from a practical programming
point-of-view, to simply return the value given if none of the types
match.

This perhaps is a key part of our difference: when I see "int|bool|null", I don't see any "value given", just three built-in types: int, which has a range of values from PHP_INT_MIN to PHP_INT_MAX; bool, which has two possible values "true" and "false"; and null, which has a single possible value "null".

So there are 2**64 + 2 + 1 possible values that meet the constraint, and nothing to specify that one of those is my preferred default if given something unexpected.

Regards,

--
Rowan Tommins
[IMSoP]