Donate to protect them directly or help international organizations.
Null Return Types
The ability to return nulls make it all too easy to write brittle software. Consider this method signature:
public function findOrder(int $orderNumber): ?Order {}
As we start using this method, we realize that null can mean many things. For example:
- A record with this number does not exist in the DB.
- We failed to load the existing record because of a mistake in our code.
- We encountered a problem from which we could not recover.
There's not always an easy way to tell why it returned null, especially if most methods in the application or libraries can return null. This is a burden on whoever uses this method.
Record Not Found
$order = $this->orders->findOrder($orderNumber);
When we call findOrder
, we have to always remember to check for null, or risk having a bug later on. Sometimes, the bug is too far removed from the original call, making it a nightmare to debug. We can potentially have a long chain of method calls, traversing dozens of classes, so it won't always be clear where the null originated from.
Mistakes in Our Code
public function findOrder(int $orderNumber): ?Order
{
return $this->findBy([
'number' => $orderNumber
]);
}
Perhaps the order numbers in the database are zero-padded strings (00001), but the int
argument doesn't carry the leading zeroes. When we get a null, we might assume that the record is simply not found, but in reality, this is a programming error.
Nulls as Error Management
Sometimes, developers encounter problems with which they cannot deal in the current context, so they opt to return a null. Here, strtotime
returns false when a date is invalid ('2020-13-01'). The developer chose to return null in that case.
public function findOrder(int $orderNumber): ?Order
{
// ...
if (!$timestamp = strtotime($order->date)) {
return null;
}
$order->timestamp = $timestamp;
return $order;
}
Fail Fast and Use Exceptions
We should return as early as possible. There is no point in continuing execution if there's a problem. We should also avoid using nulls as a substitute for error management.
With that in mind, the previous example could be rewritten like this:
public function findOrder(OrderNumber $orderNumber): Order
{
$order = $this->findBy([
'number' => $orderNumber
]);
if ($order === null) {
throw new OrderNotFound();
}
if (!$timestamp = strtotime($order->date)) {
throw new InvalidDate();
}
$order->timestamp = $timestamp;
return $order;
}
The intent is clearer here. We can only return an Order
. If it is not found, we throw OrderNotFound
, which can be later converted to a 404 for the user. In some languages, there are alternative solutions such as theOptional
return type, which forces the developer to handle a potentially missing value.
If the date cannot be parsed, such as due to corrupt data, then we throw InvalidDate
. This should never be thrown, but if it does, it means that we need to fix the underlying problem. Alternatively, we could rely on the built-in DateTime
class, which already throws an exception for invalid dates.
For the error concerning zero-padded order numbers, we replace the int
with OrderNumber
in the method's signature. OrderNumber
contains only one property, and throws an exception if we instantiate using an invalid value. This is what the class might look like:
final class OrderNumber
{
public function __construct(private string $orderNumber)
{
if ($orderNumber === '') {
throw new InvalidOrderNumber($orderNumber);
}
}
public function getValue(): string
{
return $orderNumber;
}
}
Once again, this should only happen if there is a programming error.
With this approach, there is no forgetting to check the returned value, wondering what the null means, digging for its origin, or silencing potentially bigger problems.
Related Articles
Changelog
2021-01-09: Modified the examples to avoid the need for additional clarification, and to keep the article more focused on the main point. Replaced the value object with one that provides a better lesson.
Learn about more topics