Creating a type-safe pipe() in PHP
I am an absolute fan of pipeline-oriented programming. In this article I want to show you a few ways in which Scala makes this way of working very natural, and then see how we can mimic a fraction of that power in PHP.
Enter Scala
I wrote a program that takes a string, turns it into a char array, turns each char into an Integer, then sums it up and prints it.
val myValue = "Hello World"
.toCharArray
.map(_.toInt)
.sum
println(myValue) // 1052
This example works out of the box, but let's say we add a custom doubleString() function into our code. Now this might look more like code you're used to:
def doubleString(string: String): String =
s"$string $string"
val myValue = "Hello World"
val myValue2 = doubleString(myValue)
myValue3 = myValue2
.toCharArray
.map(_.toInt)
.sum
Scala has a solution for this, namely it lets you turn ordinary functions into extension methods:
extension(string: String)
def double: String = s"$string $string"
val myValue = "Hello World"
.double
.toCharArray
.map(_.toInt)
.sum
What happened here: We added a new method to the java.lang.String type on the fly, and then use it in a chainable fashion π€―
With everything as an object and extension methods you already have enough power to model pretty much every program in this fashion.
However, there is an additional utility we can use, in case we do not want to turn our functions into extension methods, but still want to write chainable code.
By importing scala.util.chaining._
you get access to the pipe() method on all types, which lets you send any object through any function in a chainable and type-safe way.
import scala.util.chaining._
def double(a: Int): Int = a * 2
def triple(a: Int): Int = a * 3
def add1(a: Int): Int = a + 1
def toCrazyString(a: Int, string: String): String = string.repeat(a)
val myValue = 2
.pipe(double)
.pipe(triple)
.pipe(add1)
.pipe(toCrazyString(_, "ΓΌ"))
println(myValue) // ΓΌΓΌΓΌΓΌΓΌΓΌΓΌΓΌΓΌΓΌΓΌΓΌΓΌ
What I find very beautiful about this is, that I can express every intention, every semantic unit of my code in a single line. If a transformation needs multiple lines, I can define it as a function elsewhere, store it in a variable, and pass it in.
As a PHP dev, my life is now in shambles... Or?
If you continue reading after this, you are apparently not ready to switch from PHP to Scala, for whatever reason. But can we somehow replicate this coding style?
A failed attempt: using a Trait
My first idea was to add a trait, like this:
trait Chainable
{
public function pipe(callable $callback)
{
return $callback($this->value);
}
}
And then just call:
readonly class User
{
use Chainable;
public function __construct(
public string $firstName,
public string $lastName
) {}
}
$user = new User("John", "Doe");
$allCapsFullName = $user
->pipe(fn($user) => "$user->firstName $user->lastName")
->pipe(fn($fullName) => strtoupper($fullName));
But there is a problem! Because after calling the pipe() the first time, we get a string as return value. We then call pipe() on a string, which leads to a runtime error. Adding this trait to every single class would be madness. But worse: since we cannot add a trait to existing types like string or int, this solution simply does not work.
A solution: Wrapper class
An alternative solution is like a mix of both approaches we have seen in Scala. We add a pipe() method, but instead of adding it to every possible type, we create a Generic type that can hold anything (string, int, array<User>, ...). The wrapper will always have access to the pipe() method, which returns a new wrapper. At the end we have to do one more step to extract the internal value.
<?php
namespace App\Utils;
/**
* Wrapper class to enable chainable pipe transformations.
*
* @template T
*/
class Chaining
{
/**
* @var T
*/
private $value;
/**
* @param T $value
* @return Chaining<T>
*/
public static function of($value): Chaining
{
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 Chaining<U>
*/
public function pipe(callable $callback): Chaining
{
return new self($callback($this->value));
}
/**
* Unwrap the final value.
*
* @return T
*/
public function get()
{
return $this->value;
}
}
Chaining is a generic type, accepting a template T
. This means, if I create a Chaining<int>
, its internal value is of type int
. If I create a Chaining<User>
its internal value is of type User
.
Since the get() method has an annotated return type of T
(our template type), the return type is whatever T
was at the creation of the object itself.
The pipe() method is where the real magic happens. It takes a template U
, a callable(T): U
and returns a Chaining<U>
. What this means, in simpler terms: pipe() takes a function that turns the current value of type T into a value of type U, and then returns a Chaining of the new type. Since pipe() returns a Chaining<U>, we can call pipe() again and again, no matter what the internal type is, and keep adding new transformations. Note: T and U can have the same underlying type, e.g. you can pass a transformation add1(int): int
. But they can be different, that is why we need to differentiate between input and output type.
Technically, the code will run without all of these annotations. But we need them if we want type-safe code, to prevent bugs and mistakes. I will explain more about this in a second.
Here is how we can use it:
$myValue = Chaining::of("Hello World")
->pipe(fn($item) => str_split($item))
->pipe(fn($item) => array_map(fn($char) => ord($char), $item))
->pipe(fn($item) => array_sum($item))
->get();
echo $myValue . PHP_EOL; // 1052
Note quite as elegant as Scala, but it's something!
What about Type Safety?
In order to make the code type safe you need two things:
- PHPStan (static analyzer)
- The annotations I added to the code
PHPStan can "read" your code without actually running it and can detect if you are using it in an unsound way. The good part about PHPStan is, it also knows type inference. You can imagine it as a form of reverse engineering. In the following example, PHPStan keeps track of the types at all times, thanks to our parametrized type annotations:
$charsToOrd = fn($item) => array_map(fn($char) => ord($char), $item);
$myValue = Chaining::of("Hello World") // Chaining<string>
->pipe(fn($item) => str_split($item)) // Chaining<array<string>>
->pipe($charsToOrd) // Chaining<array<int>>
->pipe(fn($item) => array_sum($item)) // Chaining<int>
->get(); // int
This is incredibly helpful for preventing bugs, and if you enable PHPStan in your PhpStorm configuration, you will even see these potential errors while you are typing your code. (There is also a PHPStan plugin for VS Code, btw)
Here is an example:
Here we start with a Chaining of the number 3, double it, add 1, then turn it into a string or null based on random chance. The resulting union type of $myValue is therefore string | null
. Afterwards I pass $myValue
into a little function that expects an int
as a parameter. This is unsound code.
Another example:
Here we get a warning that this pipeline cannot work, because we pass something of type array<int, string>
into a function that expects a string
. This would either blow up during runtime, or worse, fail silently.
phpstan analyze
command to my CI/CD pipeline or a pre-commit hook, it will force me to fix my mistakes before code is allowed to be merged into the main branch.Bonus: Dumping intermediate values
If you like this pipeline-oriented approach, you might end up in situations where the end result is wrong, but you don't have any intermediate variables to print.
You can add a dump() method to your Chaining class, that dumps its value but lets you continue chaining:
/**
* Dump the value to the console.
*
* @return Chaining<T>
*/
public function dump(?string $message = null): Chaining
{
if ($message) {
echo $message . ': ';
}
var_dump($this->value);
return $this;
}
Then, you can add dump() to any step you like:
$charsToOrd = fn($item) => array_map(fn($char) => ord($char), $item);
$myValue = Chaining::of("Hello World")->dump("STEP 0")
->pipe(fn($item) => str_split($item))->dump("STEP 1")
->pipe($charsToOrd)->dump("STEP 2")
->pipe(fn($item) => array_sum($item))->dump("STEP 3")
->get();
Here is what the output looks like:
STEP 0: string(11) "Hello World"
STEP 1: array(11) {
[0]=>
string(1) "H"
[1]=>
string(1) "e"
[2]=>
string(1) "l"
[3]=>
string(1) "l"
[4]=>
string(1) "o"
[5]=>
string(1) " "
[6]=>
string(1) "W"
[7]=>
string(1) "o"
[8]=>
string(1) "r"
[9]=>
string(1) "l"
[10]=>
string(1) "d"
}
STEP 2: array(11) {
[0]=>
int(72)
[1]=>
int(101)
[2]=>
int(108)
[3]=>
int(108)
[4]=>
int(111)
[5]=>
int(32)
[6]=>
int(87)
[7]=>
int(111)
[8]=>
int(114)
[9]=>
int(108)
[10]=>
int(100)
}
STEP 3: int(1052)
Summary
In this article I showed how Scala's object model and extension methods allow to chain anything you want, including base types like String or Int, and then I showed you how to implement a weaker, but usable, replacement for this in PHP via a homebrewed Chaining wrapper. I also showed you how to build it in a type-safe way with PHPStan and annotations, as well as how to dump intermediate results at any point in your chain.
We took this Scala code:
And instead of using normal PHP:
We turned it into this:
I admit, in this comparison, my pipeline solution looks a bit ridiculous. But I prefer it anyway - and in case you like this functional style too, I hope you found my article helpful.
Next Up
I will write more about PHPStan in the upcoming days, with step-by-step guides how to install and make use of it. You can also expect more Scala content.
Liked the article?
Didn't like it enough to warrant a donation? No hard feelings! π Though here's a reminder to sign up to my newsletter if you want to read more articles like this!