I had an interesting use-case with a customer for which I provide consulting services: they needed multiple fields to be marked as "optional".
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
?
final class UpdateUser {
private function __construct(
public readonly int $id,
public readonly string|null $email,
public readonly string|null $description,
public readonly PaymentType|null $paymentType
) {}
public static function fromPost(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']);
}
return new self($post['id'], $email, $description, $paymentType);
}
}
On the usage side, we have something like this:
final class HandleUpdateUser
{
public function __construct(private readonly Users $users) {}
public function __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.
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 */
final class OptionalField
{
/** @param Contents $value */
private function __construct(
private readonly bool $hasValue,
private readonly mixed $value
) {}
/**
* @template T
* @param T $value
* @return self<T>
*/
public static function forValue(mixed $value): self {
return new self(true, $value);
}
/**
* @template T
* @param \Psl\Type\TypeInterface<T> $type
* @return self<T>
*/
public static function forPossiblyMissingArrayKey(array $input, string $key, \Psl\Type\TypeInterface $type): self {
if (! array_key_exists($key, $input)) {
return new self(false, null);
}
return new self(true, $type->coerce($input[$key]));
}
/**
* @template T
* @param pure-callable(Contents): T $map
* @return self<T>
*/
public function map(callable $map): self
{
if (! $this->hasValue) {
return new self(false, null);
}
return new self(true, $map($this->value));
}
/** @param callable(Contents): void $apply */
public function apply(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?
We can now re-design UpdateUser
to leverage this abstraction.
Notice the lack of conditionals:
class UpdateUser {
/**
* @param OptionalField<non-empty-string> $email
* @param OptionalField<string> $description
* @param OptionalField<PaymentType> $paymentType
*/
private function __construct(
public readonly int $id,
public readonly OptionalField $email,
public readonly OptionalField $description,
public readonly OptionalField $paymentType,
) {}
public static function fromPost(array $post): self
Assert::keyExists($post, 'id');
Assert::positiveInteger($post['id']);
return new 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:
final class HandleUpdateUser
{
public function __construct(private readonly Users $users) {}
public function __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
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
:
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