Type matching in PHP

in #php4 years ago (edited)

One of the nice features of Rust is the match keyword. match is similar to switch, but with two key differences:

  1. It requires an exhaustive match, that is, every possible value must be accounted for or a default must be provided.
  2. match is an expression, meaning you can assign the return value of one of its branches to a variable.

That makes match extremely useful for ensuring you handle all possibilities of an enumerated type, say, if using an Optional or Either for error handling. Which... is something I've been experimenting with in PHP.

It's hard to make a PHP equivalent of match that forces an exhaustive match, as PHP lacks enumerated types. However, emulating an expression match turns out to be pretty easy in PHP 7.4, and kind of pretty, too.

The concept

Sometimes we are dealing with a variable of unclear type, but the possible types it could be is a closed set. That could be

  • a Maybe or Either style monad,
  • a pseudo-enumerated type (which is the fancy way of saying a limited number of classes that implement a certain interface),
  • a union return value in PHP 8

or various other cases. In that situation we want to cleanly branch our logic based on the type, at least somewhat, but polymorphic inheritance on the object isn't sufficient; the thing we want to branch doesn't make logical sense as a method of the object.

The naive way

So we have a $var that we know is of type A|B|C, but not which of those types it is. We could just check it manually:

if ($var instance of A) {
    $result = ...;
} else if ($var instance of B) {
    $result = ...;
} else if ($var instance of C) {
    $result = ...;
} else {
    $result = 'Some default';
}

But that's kind of ugly and doesn't really express visually that we're doing a type-based branch. It also doesn't do a good job of visualizing that "set $result" is the end goal. Any of those if blocks could technically do anything and not set $result, in which case we can't guarantee that it's now set without examining each branch.

We can do better.

Short-lambdas to the rescue

PHP 7.4 introduced "short lambdas", which are a short-hand way to write single-statement anonymous functions that don't even look much like functions. They look more like normal expressions. That's the point. That allows us to refactor the above block into something like this:

$result = match($var, [
    A::class => fn() => 'A',
    B::class => fn() => 'B',
    C::class => fn() => 'C',
], fn() => 'Default');

That is, we provide a literal map from class type to a single statement to run, wrapped into a closure. That closures, because it's a short lambda, can auto-capture variables from its scope, including $var itself. Because it's a function, none of them run at this point; they're just values, like any other value, that do nothing until invoked. So, let's invoke them:

function match($var, array $map, callable $default = null) {
    foreach ($map as $type => $fn) {
        if ($var instanceof $type) {
            return $fn();
        }
    }
    if ($default) {
        return $default();
    }
    throw new TypeError();
}

That is, scan through the map of callables until we find one that type matches, then call that and return the result. If none match, we also support a default fallback. If there is no fallback provided, well, the developer screwed up so throw a TypeError. (Error indicating "the dev screwed up and the code is wrong", not "some oddball exceptional case happened", for which you'd likely throw InvalidArgumentException or similar.)

This approach guarantees that $result is now set, and makes that clear visually; if it's not, it's a developer error and an appropriate error is thrown.

An alternate implementation would be to pass $var as an argument to each lambda, but since it's auto-captured anyway if necessary that would in practice just mean more typing.

It's not as nice as Rust's match keyword, but once you start using PHP for functional programming (yep, that's a thing) I expect it will come in quite handy.

OMG Arrays?

Yes, it's an actual use case for a literal associative array in PHP, in which an object would not be superior.

I'm scared, too.