Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save devnix/9471f7f6012f1aab21235a1f2a091bda to your computer and use it in GitHub Desktop.
Save devnix/9471f7f6012f1aab21235a1f2a091bda to your computer and use it in GitHub Desktop.
Handling optional input parameters in PHP with `vimeo/psalm` and `azjezz/psl`

Handling optional input parameters in PHP with vimeo/psalm and azjezz/psl

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?

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);
    }
}

A lot of decisions to be taken on the call-side!

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.

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 */
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?

Using the new abstraction

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

"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 a type 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:

@azjezz is also working on an Optional implementation for azjezz/psl:

A note on Optional from Java

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> represents T|null
  • In this example, OptionalField<T> represents T|absent
    • T may as well be nullable. PHP correctly deals with null, so this abstraction works also for T|null|absent
    • PHP's handling of null is fine: it is not a problematic type, like it is in Java
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment