Quantcast
Viewing latest article 10
Browse Latest Browse All 15

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

handling-optional-input-fields-with-type-safe-abstractions.md

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?

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/pslPsl\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 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

Viewing latest article 10
Browse Latest Browse All 15

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>