levelmax World 1 Strict Types
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. B
L
Level 1.4 ~12 min Target: PHPStan level 5

Nullable versus union

null is the easiest type to reach for and the hardest to read. Does ?Card mean "no card yet", "paid some other way", "card removed", or "something went wrong upstream"? The type can't say, so every caller guesses.

Two nullables, four states

A payment is made either by card or with a gift card. Modelled with two nullable properties:

public ?Card $card,
public ?GiftCard $giftCard,

That type allows four combinations, and only two of them make sense. Every caller now has to handle "both" and "neither", or hope they never happen.

A union says exactly what's allowed:

public Card|GiftCard $paidWith,

Exactly one of the two, always present, enforced by PHP when the object is built. Callers narrow it with instanceof, and PHPStan follows that narrowing: inside if ($paidWith instanceof GiftCard), it knows you have a GiftCard, and in the else, a Card.

When null is the honest answer

null is right when something can genuinely be absent and absence means one thing. A gift message is a good example: there is one, or there isn't. ?string $giftMessage says that clearly, as long as there's only one way to say "none". If "", " " and null all mean "no message", callers have to check all three, and someone eventually prints an empty gift card. Normalise at the boundary so null is the only one.

Power-up

Before
<?php

final class Payment
{
    public function __construct(
        public readonly int $pence,
        public readonly ?Card $card = null,
        public readonly ?GiftCard $giftCard = null,
        public readonly ?string $giftMessage = null, // or '', or '   '
    ) {}
}
After
<?php

final class Payment
{
    public readonly ?string $giftMessage; // null is the only "none"

    public function __construct(
        public readonly int $pence,
        public readonly Card|GiftCard $paidWith,
        ?string $giftMessage = null,
    ) {
        $giftMessage = $giftMessage === null ? null : trim($giftMessage);
        $this->giftMessage = $giftMessage === '' ? null : $giftMessage;
    }
}

Quick check

1. A payment has ?Card $card and ?GiftCard $giftCard. How many combinations does the type allow?
2. Inside if ($payment->paidWith instanceof GiftCard) { ... } else { ... }, what does PHPStan know about paidWith in the else branch?
3. At level 5, you pass a ?string to a parameter declared string. What does PHPStan report?

CHALLENGE

An order goes either to an address or to a pickup point, never both and never neither. Order models that with two nullable properties, so it allows all four combinations. Delivery instructions are optional, but customers often submit a blank box, and " " isn't an instruction.

ShippingLabel (read only) already expects the honest version. Change src/Order.php so it has:

  • a destination that is an Address or a PickupPoint, passed second to the constructor
  • optional instructions as the third argument, trimmed, where a blank value becomes null
Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.