Donate to protect them directly or help international organizations.
Null Hell and How to Get Out of It
July 3rd, 2019
When used without a second thought, nulls can make your life miserable and you possibly don't even realize that they're the ones that cause you so much pain. Let me explain.
Default Arguments
You've all seen a method that takes many arguments, but more than half of them are optional. You then end up with something that looks like this:
public function insertDiscount(
string $name,
int $amountInCents,
bool $isActive = true,
string $description = '',
int $productIdConstraint = null,
DateTimeImmutable $startDateConstraint = null,
DateTimeImmutable $endDateConstraint = null,
int $paymentMethodConstraint = null
) : int
In the case above, we want to create a discount in the database that applies everywhere by default, but then can optionally be inactive upon creation, apply to specific products, be valid during specific times or be applied when a user choose a specific payment method (credit card, e-wallet, loyalty points, etc.)
If you want to create a discount for a specific payment method, you'd call it like this:
insertDiscount('Discount name', 100, true, '', null, null, null, 5);
This works, but makes absolutely no sense to the person reading this. Reasoning about this code becomes extremely hard, so we can't easily maintain this application.
Let's dive into this example one parameter at a time.
What is a Valid Discount?
We already established that a discount without constraints applies everywhere. Therefore, a valid discount contains everything except the constraints, which we can add later.
isActive
has a default value of true
. This means that the method can be called like this:
insertDiscount('Discount name', 100);
Just by reading the code, I don't know that it will be active. I would have to check the method's signature for all the defaults to know that.
Now imagine you need to read 200 lines of code. Will you really want to check each signature for the missing information? I would prefer to just read the code without the need to look up anything.
The same goes for the description. If it's empty by default, it might cause a whole lot of problems down the line, where you would expect a description. For example, you may end up with the description printed on a receipt, but because it's empty, the user will just see a blank line with an amount next to it. The system should not allow for such a thing to occur.
I would rewrite this method as such:
public function insertDiscount(
string $name,
string $description,
int $amountInCents,
bool $isActive
) : int
I completely removed the constraints, since we decided to add them later, using separate methods. Since all parameters are now required, they can go in any order. I therefore chose to put the description right after the name, because it's more readable to have those together.
insertDiscount(
'Discount name',
'Discount description',
100,
Discount::STATUS_ACTIVE
);
I also threw in a little extra. I used constants for the status. Now you don't have to look at the signature to know what the true
means. It's obvious that we're creating an active discount. We could have used a value object for the amount, but that would require a whole blog post on value objects.
Adding the Constraints
The next step is to add the various constraints. To avoid null, null, null
argument hell, we'll create separate methods.
public function addProductConstraint(
Discount $discount,
int $productId
) : Discount;
public function addDateConstraint(
Discount $discount,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate
) : Discount;
public function addPaymentMethodConstraint(
Discount $discount,
int $paymentMethod
) : Discount;
At this point, if we want to create a new discount with a specific contraints, we'd do it like this:
$discountId = insertDiscount(
'Discount name',
'Discount description',
100,
Discount::STATUS_ACTIVE
);
addPaymentMethodConstraint(
$discountId,
PaymentMethod::CREDIT_CARD
);
Now compare this to the original call. You will see how big a difference that makes in readability.
Object Nulls
Allowing nulls in properties often cause problems as well. I can't say how often I see things like this:
$currencyCode = strtolower(
$record->currencyCode
);
Boom! Can't feed a null to strtolower
. This is because the developer forgot that the currencyCode
can be null. Since many developers still don't use an IDE or suppress warnings, this can go unnoticed for years. The error will rot in some unread log and clients will report intermittent issues that don't seem related to this, so nobody will bother to look at this line of code.
We can, of course, add null checks everywhere we access currencyCode
. Then we end up with another kind of hell:
if ($record->currencyCode === null) {
throw new \RuntimeException('Currency code cannot be null');
}
if ($record->amount === null) {
throw new \RuntimeException('Amount cannot be null');
}
if ($record->amount > 0) {
throw new \RuntimeException('Amount must be a positive value');
}
But this is not a great solution. Besides cluttering your method, you'll now have to remember to repeat this everywhere. Oh, and don't you dare forget to add another one of those checks in every single method whenever you add another property. Luckily, there is a simple solution: Value Objects.
Value Objects
Value objects are powerful yet simple things. The problem that we were trying to solve earlier was that we need to constantly revalidate all of our properties. We do this because at every step, we don't know whether we can trust the object's properties to be valid. What if we could?
In order to trust the values, we need two characteristics: for them to be validated and a guarantee that they haven't been changed since the validation. Take a look at this class:
final class Amount
{
private $amountInCents;
private $currencyCode;
public function __construct(int $amountInCents, string $currencyCode) : self
{
Assert::that($amountInCents)->greaterThan(0);
$this->amountInCents = $amountInCents;
$this->currencyCode = $currencyCode;
}
public function getAmountInCents() : int
{
return $this->amountInCents;
}
public function getCurrencyCode() : string
{
return $this->currencyCode;
}
}
I'm using the beberlei/assert
package. It throws an exception whenever a validation fails. This is the same as throwing an exception on nulls in the original code, except we moved the validation to this constructor.
Because we're using type declarations, we are guaranteed that the type is correct as well, so we can't feed an int
into strtolower
. If you're using an older version of PHP that doesn't support type declarations for primitives, then you can leverage this package to check for types as well using ->integer()
and ->string()
.
Once instantiated, values cannot be changed because we have only getters but no setters. This is called immutability. The addition of final
prevents someone from extending this class to add setters or magic methods. If you see an Amount $amount
in your method parameters, you can be 100% sure that all of its properties have been validated and safe to use. If it's not valid, there's no way to instantiate it in the first place.
With this value object available, we can push the insertDiscount
example further:
$discount = new Discount(
'Discount name',
'Discount description',
new Amount(100, 'CAD'),
Discount::STATUS_ACTIVE
)
insertDiscount($discount);
Notice how we first instantiate the Discount
and even use the Amount
as an argument. This ensures that insertDiscount
receives a valid object, in addition to this whole block of code being that much easier to reason about.
A Null Horror Story
Let's look at an interesting way in which a null can wreak havoc in an application. The idea was to retrieve a collection from the database and filter it further.
$collection = $this->findBy(['key' => 'value']);
$result = $this->filter($collection, $someFilterMethod);
if ($result === null) {
$result = $collection;
}
If the result is null, reassign the original collection? This is problematic, since filter
retuned null when no matches were found. So basically, if everything is filtered out, it ignored the filter and returned everything. This completely broke a feature.
Why was the collection reassigned? We'll never know. I suspect that the developer had a certain assumption about what the null meant in that context, which turned out incorrect.
This is the problem with nulls. In most situations, they actually don't mean anything and so we're left wondering how to react to them. It's very easy to make a wrong assumption. An exception, on the other hand, is very clear:
try {
$result = $this->filter($collection, $someFilterMethod);
} catch (CollectionCannotBeEmpty $e) {
// ...
}
This code is unambiguous. The developer can hardly misinterpret what it means.
Is it Worth the Effort?
This all looks like extra effort for writing the same code. Yes, it is extra effort. However, you will spend a lot more time reading and understanding code, so that effort will be justly rewarded. Every extra hour that I spend writing code properly saves me literally days when I have to modify the code or add new features. Think of it as a guaranteed high-yield investment.
That's it for my null rant. I hope that this helps you write more maintainable code.
Previous: Randomizing Test Data is a Bad Idea Next: If We Asked Athletes the Questions That We Ask Developers