Type-safe pipe() in PHP: part 2
In a previous article I explained how Scala allows seamless function chaining via extension methods and pipe(), and then went on to replicate something """kind of""" similar in PHP:
Since then I received some feedback and also used that helper a lot, and made some significant changes and improvements. I want to showcase the next iteration of it here.
Box is my new friend
I renamed the class Chaining
to Box
. It's short and crisp.
Adding a combined pull() method
Take the following example:
$value = Box::of(5)
->pipe(fn($val) => $val + 1)
->pipe(fn($val) => $val * 2)
->pipe(fn($val) => $val - 1)
->get(); // $value == 11
Wouldn't it be nice to not have that additional get() at the bottom? The solution is very straight forward. I just added a new method pull() that is like pipe() + get(). For symmetry reasons I wanted its name to be 4 characters long as well.
$value = Box::of(5)
->pipe(fn($val) => $val + 1)
->pipe(fn($val) => $val * 2)
->pull(fn($val) => $val - 1); // $value == 11
So much nicer, isn't it?? 👀
The implementation is straight forward:
/**
* @template U
* @param callable(T): U $callback
* @return U
*/
public function pull(callable $callback)
{
return $callback($this->value);
}
Compared to our pipe()
method, which returns a Box<U>
, pull()
skips the re-boxing part and returns U
(which is whichever type is returned by the $callback)
For comparison, here is the pipe() method:
/**
* @template U
* @param callable(T): U $callback
* @return Box<U>
*/
public function pipe(callable $callback): Box
{
return new self($callback($this->value));
}
Fixing the broken factory
In my old article, the public static function of()
factory method has wrong annotations, but I only discovered this at PHPStan level 9.
Here is the corrected version, where PHPStan will infer the resulting box type accurately:
/**
* Create a new Box instance.
*
* @template U
* @param U $value
* @return Box<U>
*/
public static function of($value): Box
{
return new self($value);
}
Notice that this method creates its own template U
, which is different from the class' template T
. As it turns out, static methods have to be treated very carefully, and using T makes no sense here because it is part of the Box instance after being constructed, whereas U does not yet belong to any instance, but tells us what kind of Box<U> will be created (which then becomes a Box<T> from its own perspective). Alternatively here is another explanation: Trust me bro, it just works 👌
Adding an assert() method
I added a chainable assert() method:
/**
* Run an assertion against the value.
* Example of a simple strict equality check: ->assert(5)
* Example of a callback check: ->assert(fn($x) => $x > 5)
*
* @template U
* @param U|callable(T):bool $check
* @return Box<T>
*/
public function assert(mixed $check): Box
{
$isClosure = is_callable($check) && !is_string($check);
$pass = $isClosure
? $check($this->value)
: $this->value === $check;
if (! $pass) {
$message = $isClosure
? 'Value did not pass the callback check.'
: sprintf(
'Failed asserting that two values are the same. Expected %s, got %s.',
var_export($check, true),
var_export($this->value, true)
);
throw new LogicException($message);
}
return $this;
}
I wanted it to be free from any package dependencies so I simply use a LogicException. Thanks to var_export(), every value will be transformed into a string that represents the data as it looks in PHP. If $check is a callable, var_export() gives quite confusing results so in that case I replace its message with: "Value did not pass the callback check."
Notice you can either pass in a value
$value = Bof::of(5)->assert(5)->unbox();
Or a callable:
$value = Box::of(5)->assert(fn($x) => $x > 4)->unbox();
In a test, this lets you fluently assert (and therefore document desired behavior) on every step of the way:
public function testBoxComplexTransformation(): void
{
$result = Box::of('Hello')
->pipe(strtoupper(...))->assert('HELLO')
->pipe(strrev(...))->assert('OLLEH')
->pipe(str_split(...))->assert(['O', 'L', 'L', 'E', 'H'])
->pipe(fn($arr) => array_map(ord(...), $arr))->assert([79, 76, 76, 69, 72])
->pipe(array_sum(...))->assert(372)->assert(fn($x) => $x > 0)
->pull(fn($x) => $x + 100);
$this->assertSame(472, $result);
}
If I change the asserted value for the char to number conversion to ->assert([79, 76, 76, 69, 71])
, I get the following output when running my test:
LogicException : Failed asserting that two values are the same. Expected array (
0 => 79,
1 => 76,
2 => 76,
3 => 69,
4 => 71,
), got array (
0 => 79,
1 => 76,
2 => 76,
3 => 69,
4 => 72,
).
/app/app/Helper/Box.php:84
/app/tests/Unit/Helper/BoxTest.php:44
The updated source code
Before we continue, I want to share the full, updated source code of the new Box helper:
<?php
declare(strict_types=1);
namespace App\Helper;
use LogicException;
/**
* Wrapper class to enable chainable pipe transformations.
*
* @template T
*/
class Box
{
/**
* @var T
*/
private $value;
/**
* @template U
* @param U $value
* @return Box<U>
*/
public static function of($value): Box
{
return new self($value);
}
/**
* @param T $value
*/
public function __construct($value)
{
$this->value = $value;
}
/**
* Apply a transformation function to the value.
*
* @template U
* @param callable(T): U $callback
* @return Box<U>
*/
public function pipe(callable $callback): Box
{
return new self($callback($this->value));
}
/**
* Unwrap the final value.
*
* @return T
*/
public function unbox()
{
return $this->value;
}
/**
* Unwrap the value and apply a final transformation on it.
* Can be used instead of `unbox` to terminate the sequence.
*
* @template U
* @param callable(T): U $callback
* @return U
*/
public function pull(callable $callback)
{
return $callback($this->unbox());
}
/**
* Run an assertion against the value.
* Example of a simple strict equality check: ->assert(5)
* Example of a callback check: ->assert(fn($x) => $x > 5)
*
* @template U
* @param U|callable(T):bool $check
* @return Box<T>
*/
public function assert(mixed $check): Box
{
$isClosure = is_callable($check) && !is_string($check);
$pass = $isClosure
? $check($this->value)
: $this->value === $check;
if (! $pass) {
$message = $isClosure
? 'Value did not pass the callback check.'
: sprintf(
'Failed asserting that two values are the same. Expected %s, got %s.',
var_export($check, true),
var_export($this->value, true)
);
throw new LogicException($message);
}
return $this;
}
/**
* Dump the value to the console.
*
* @return Box<T>
*/
public function dump(?string $message = null): Box
{
if ($message) {
echo $message . ': ';
}
var_dump($this->value);
return $this;
}
}
If you like it, consider checking out the project on Github and leaving a star 🤩
Just bear in mind: For this thing to be typesafe at all, PHPStan is required. The reason for this is that PHP is a toy. Here is a quick-start guide for how to install PHPStan into a Laravel project (or any other PHP project):
PHPStan level 9: Endboss
Let's take the following code:
readonly class UserDto
{
/** @param array<string, mixed> $data */
public static function fromArray(array $data): self
{
// add validation if you so desire
return new self(name: $data['name'], email: $data['e-mail']);
}
private function __construct(
public string $name,
public string $email
) {}
}
function describe_user(UserDto $user): string
{
return sprintf('User %s with email %s', $user->name, $user->email);
}
$rawApiResponse = '{"name": "John Doe", "e-mail": "john.doe@example.org"}';
$result = Box::of($rawApiResponse)
->pipe(fn($val) => json_decode($val, true))
->pipe(fn($val) => UserDto::fromArray($val))
->pull(fn($val) => describe_user($val));
echo $result; // "User John Doe with email john.doe@example.org"
In the above code:
- I define a UserDto with private constructor and a public factory method.
- Next, there is a describeUser() function which expects a UserDto as input.
- I prepare a fake api response, a JSON string containing an object with the fields "name" and "e-mail"
- Then I put the content we received from the api into a box and apply three functions on it, first decoding the json, then creating a UserDto from the parsed array, and last transforming the $user into a description.
The above code "compiles" just fine on PHPStan level 8.
Enter Level 9 (a.k.a complaining about 'mixed')
But on level 9, we start to get some issues:
Now before I continue on yapping: How fricking magical is it that PHPStan is able to track these types so accurately as the data flows through the program?
Anyway, the error reads: phpstan: Parameter #1 $data of static method UserDto::fromArray() expects array<string, mixed>, mixed given.
The issue is that json_decode()
is inherently untyped and can return just about anything, as illustrated below:
var_dump(json_decode('[]')); // array
var_dump(json_decode('{}')); // object
var_dump(json_decode('true')); // bool
var_dump(json_decode('false')); // bool
var_dump(json_decode('null')); // null
var_dump(json_decode('5')); // int
My UserDto::fromArray()
method annotates that it requires an array<string, mixed>
(so an array with string indices and mixed values), but json_decode()
cannot guarantee that it will return an array, let alone a map.
as_map()
There are many ways to tackle this problem, but I decided to create a new function called as_map()
, which accepts $data of ANY type, and returns $data of a guaranteed more specific shape (with the raw power of brute force and throwing exceptions):
/** @return array<string, mixed> */
function as_map(mixed $data): array
{
if (!is_array($data)) {
throw new RuntimeException('required to be array');
}
$keys = array_keys($data);
if ($keys !== array_filter($keys, 'is_string')) {
throw new RuntimeException('all keys must be strings');
}
return $data;
}
Afterwards, this code is type-safe, because it is no longer possible to accidentally perform an operation on a type that does not support it, and PHPStan is satisfied:
$result = Box::of($rawApiResponse)
->pipe(fn($val) => json_decode($val, true, flags: JSON_THROW_ON_ERROR))
->pipe(fn($val) => as_map($val))
->pipe(fn($val) => UserDto::fromArray($val))
->pull(fn($val) => describe_user($val));
I threw in in a JSON_THROW_ON_ERROR for good measure, because let's be real, returning `false` when a json string entirely fails to parse is batshit insane.
Type-Safety
What does type-safe mean: It means that we never execute operations on data whose shape does not allow these operations - this kind of error is the most uncontrollable, most common and most insidious; a maddening combination, and the reason why there are so many statically typed languages, especially among modern ones. Let's not forget PHP and JS both launched in 1995, and Python in 1991, and all three languages evolved to either have a (sub-par) static type system, or in case of JS, an entirely new language TypeScript that fixes this flaw of lacking type safety. It is time to let the dynamic paradigm die.
Our code can still crash due to various RuntimeExceptions within json_decode()
as well as the shape checks performed by as_map()
, or validation within fromArray()
. But the difference is, we are making these potential crashes very explicit, so the reader can internalize all these edge-cases, discuss how to treat them and have them documented within the source code, rather than randomly running into TypeErrors in prod.
The rest of this article, with some more tidbits and struggles, is only available for Members (pro tip: get 67% off with the newcomer special)