vimeo/psalm
and azjezz/psl
Handling optional input parameters in PHP with I had an interesting use-case with a customer for which I provide consulting services: they needed multiple fields to be marked as "optional".
Example: updating a user
We will take a CRUD-ish example, for the sake of simplicity.
For example, in the following scenario, does a null
$description
mean "remove the description",
or "skip setting the description"?
What about $email
?
What about the $paymentType
?
finalclassUpdateUser {
privatefunction__construct(
public readonly int$id,
public readonly string|null$email,
public readonly string|null$description,
public readonly PaymentType|null$paymentType
) {}
publicstaticfunctionfromPost(array$post): self
{
Assert::keyExists($post, 'id');
Assert::positiveInteger($post['id']);
$email = null;
$description = null;
$paymentType = null;
if (array_key_exists('email', $post)) {
$email = $post['email'];
Assert::stringOrNull($email);
}
if (array_key_exists('description', $post)) {
$description = $post['description'];
Assert::stringOrNull($description);
}
if (array_key_exists('paymentType', $post)) {
Assert::string($post['paymentType']);
$paymentType = PaymentType::fromString($post['paymentType']);
}
returnnew self($post['id'], $email, $description, $paymentType);
}
}
A lot of decisions to be taken on the call-side!
On the usage side, we have something like this:
finalclassHandleUpdateUser
{
publicfunction__construct(private readonly Users$users) {}
publicfunction__invoke(UpdateUser$command): void {
$user = $this->users->get($command->id);
if ($command->email !== null) {
// note: we only update the email when provided in input$user->updateEmail($command->email);
}
// We always update the description, but what if it was just forgotten from the payload?// Who is responsible for deciding "optional field" vs "remove description when not provided": the command,// or the command handler?// Is this a bug, or correct behavior?$user->setDescription($command->description);
// Do we really want to reset the payment type to `PaymentType::default()`, when none is given?$user->payWith($command->paymentType ?? PaymentType::default());
$this->users->save($user);
}
}
Noticed how many assertions, decisions and conditionals are in our code? That is a lot.
If you are familiar with mutation testing, you will know that this is a lot of added testing effort too, as well as added runtime during tests.
We can do better.
Abstracting the concept of "optional field"
We needed some sort of abstraction for defining null|Type|NotProvided
, and came up with this
nice abstraction (for those familiar with functional programming, nothing new under the sun):
/** @template Contents */finalclassOptionalField
{
/** @param Contents $value */privatefunction__construct(
private readonly bool$hasValue,
private readonly mixed$value
) {}
/** * @template T * @param T $value * @return self<T> */publicstaticfunctionforValue(mixed$value): self {
returnnew self(true, $value);
}
/** * @template T * @param \Psl\Type\TypeInterface<T> $type * @return self<T> */publicstaticfunctionforPossiblyMissingArrayKey(array$input, string$key, \Psl\Type\TypeInterface$type): self {
if (! array_key_exists($key, $input)) {
returnnew self(false, null);
}
returnnew self(true, $type->coerce($input[$key]));
}
/** * @template T * @param pure-callable(Contents): T $map * @return self<T> */publicfunctionmap(callable$map): self
{
if (! $this->hasValue) {
returnnew self(false, null);
}
returnnew self(true, $map($this->value));
}
/** @param callable(Contents): void $apply */publicfunctionapply(callable$apply): void
{
if (! $this->hasValue) {
return;
}
$apply($this->value);
}
}
The usage becomes as follows for given values:
OptionalField::forValue(123) /** OptionalField<int> */
->map(fn (int$value): string => (string) ($value * 2)) /** OptionalField<string> */
->apply(function (int$value): void { var_dump($value);}); // echoes
We can also instantiate it for non-existing values:
OptionalField::forPossiblyMissingArrayKey(
['foo' => 'bar'],
'baz',
\Psl\Type\positive_int()
) /** OptionalField<positive-int> - note that there's no `baz` key, so this will produce an empty instance */
->map(fn (int$value): string => $value . ' - example') /** OptionalField<string> - never actually called */
->apply(function (int$value): void { var_dump($value);}); // never called
Noticed the \Psl\Type\positive_int()
call?
That's an abstraction coming from azjezz/psl
, which allows for having a type declared both at runtime and at
static analysis level. We use it to parse inputs into valid values, or to produce crashes, if something is malformed.
This will also implicitly validate our values:
OptionalField::forPossiblyMissingArrayKey(
['foo' => 'bar'],
'foo',
\Psl\Type\positive_int()
); // crashes: `foo` does not contain a `positive-int`!
Noticed how the azjezz/psl
Psl\Type
tooling
gives us both type safety and runtime validation?
Using the new abstraction
We can now re-design UpdateUser
to leverage this abstraction.
Notice the lack of conditionals:
classUpdateUser {
/** * @param OptionalField<non-empty-string> $email * @param OptionalField<string> $description * @param OptionalField<PaymentType> $paymentType */privatefunction__construct(
public readonly int$id,
public readonly OptionalField$email,
public readonly OptionalField$description,
public readonly OptionalField$paymentType,
) {}
publicstaticfunction fromPost(array$post): selfAssert::keyExists($post, 'id');
Assert::positiveInteger($post['id']);
returnnew self(
$id,
OptionalField::forPossiblyMissingArrayKey($post, 'email', Type\non_empty_string()),
OptionalField::forPossiblyMissingArrayKey($post, 'description', Type\nullable(Type\string())),
OptionalField::forPossiblyMissingArrayKey($post, 'paymentType', Type\string())
->map([PaymentType::class, 'fromString'])
);
}
}
We now have a clear definition for the fact that the fields are optional, bringing clarity in the
previous ambiguity of "null
can mean missing, or to be removed".
The usage also becomes much cleaner:
finalclassHandleUpdateUser
{
publicfunction__construct(private readonly Users$users) {}
publicfunction__invoke(UpdateUser$command): void {
$user = $this->users->get($command->id);
// these are only called if a field has a provided value:$command->email->apply([$user, 'updateEmail']);
$command->description->apply([$user, 'setDescription']);
$command->paymentType->apply([$user, 'payWith']);
$this->users->save($user);
}
}
If you ignore the ugly getter/setter approach of this simplified business domain, this looks much nicer:
- better clarity about the optional nature of these fields
- better type information on fields
null
is potentially a valid value for some business interaction, but whether it was explicitly defined or not is very clear now- interactions are skipped for optional data, without any need to have more conditional logic
- structural validation (runtime type checking) is baked in
- lack of conditional logic, which leads to reduced static analysis and testing efforts
"Just" functional programming
What you've seen above is very similar to concepts that are well known and widely spread in the functional programming world:
- the
OptionalField
type is very much similar to atype Maybe = Nothing | Some T
in Haskell - since we don't have type classes in PHP, we defined some map operations on the type itself
If you are interested in more details on this family of patterns applied to PHP, @marcosh has written about it in more detail:
- http://marcosh.github.io/post/2017/06/16/maybe-in-php.html
- http://marcosh.github.io/post/2017/10/27/maybe-in-php-2.html
- https://github.com/marcosh/lamphpda/blob/5b4cddbed0ede309a6fe39a561efed7f100a5dd2/src/Maybe.php#L35-L72
@azjezz is also working on an Optional
implementation for azjezz/psl
:
Optional
from Java
A note on While discussing this approach with co-workers that come from the Java world, it became clear that a distinction is to be made here.
In Java, the Optional<T>
type was introduced to abstract nullability.
Why? Because Java is terrible at handling null
, and therefore T|null
is a bad type, when every
type in Java is implicitly nullable upfront (unless you use Checker Framework).
Therefore:
- In Java,
Optional<T>
representsT|null
- In this example,
OptionalField<T>
representsT|absent
T
may as well be nullable. PHP correctly deals withnull
, so this abstraction works also forT|null|absent
- PHP's handling of
null
is fine: it is not a problematic type, like it is in Java