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:

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.

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