Recently I responded to a Twitter thread about whether you should return null or throw an exception when an error is encountered and it got me thinking, especially when the tweets started going back and forth. I thought it would be beneficial to gather my thoughts together into something more cohesive and here we are.
Throw Exception - I'd rather my app crap out, and make use of @getsentry to log it and notify me so I can resolve is a short timeframe instead of my app silently failing and pretend everything is fine for an extended timeframe.
— Chris Shennan (@chrisshennan) October 26, 2022
We've all had this conversation
Client: Your website doesn't work
Me: Can you provide some details about the issue?
Client: It doesn't work
Returning null
No Context
Returning null
is effectively the programmatic equivalent of the above conversation. It "may" tell you that there is a problem but it provides no context about what happened or why, so debugging and resolving issues where a null value is returned become difficult.
Ambiguity
In a lot of circumstances, a null
value is to be considered an acceptable outcome but if we return null
when an error has happened then how do we tell if a null
is an acceptable outcome or an error? If we consider something like $customer->getSubscription()
which could return a Subscription object or null
if they have not purchased a subscription, then in this case, null
is an acceptable outcome.
Now let us consider what could be inside getSubscription()
. Without delving deeper into the code, getSubscription()
is effectively a black box. It could just be a couple of lines of code which return a Subscription object, or it could involve calls to an external system which manages the subscription details. i.e. if we have the following code
class Customer
{
// SubscriptionAPI Client
protected $apiClient;
public function getSubscription()
{
try {
$subscription = $this->apiClient->getSubscription();
return $subscription;
} catch (\Exception $e) {
// Something went wrong;
return null;
}
}
}
It's unclear whether a null
response is a because the customer does not have a subscription or a server error connecting to the Subscription API.
Error Masking
Returning null can result in long-running problems that are masked because the application "appears" to be working correctly. Imagine the case where we are performing a calculation based on 2 values retrieved from a database record. We could return null
if either value is empty but if we're not careful with the next bit of code, that null could be interpreted as a 0 which could result in completely incorrect values in further calculations.
The following is an overly simplistic example where I am attempting to get a LineItem total value - I've stripped out the logic that would be involved in determining the price and markup multiplier and I am just returning what would be the calculated value for simplicity and clarity.
class LineItem
{
/**
* Unit price of the line item
* @return float
*/
public function getPrice()
{
return 10.00;
}
/**
* What the markup multiplier should be
* @return float
*/
public function applyMarkup()
{
return 1.2;
}
/**
* How many are being purchased.
* @return int
*/
public function getQuantity()
{
return 2;
}
/**
* Line Item Total
* @return float|int
*/
public function getTotal()
{
return $this->getQuantity() * ($this->getPrice() * $this->applyMarkup());
}
}
In the above example running $lineItem->getTotal()
would return 24, however, let's consider what would happen if the applyMarkup()
method was to error and return null
.
class LineItem
{
/**
* Unit price of the line item
* @return float
*/
public function getPrice()
{
return 10.00;
}
/**
* What the markup multiplier should be
* @return float
*/
public function applyMarkup()
{
// Something went wrong so we're returning a null response
return null;
}
/**
* How many are being purchased.
* @return int
*/
public function getQuantity()
{
return 2;
}
/**
* Line Item Total
* @return float|int
*/
public function getTotal()
{
return $this->getQuantity() * ($this->getPrice() * $this->applyMarkup());
}
}
In this scenario, running $lineItem->getTotal()
would return 0; The application would appear to be working as no errors are being reported by your application, but the result is that you could be giving away a lot of freebies.
Throw Exception
Context
When we throw exceptions we provide context about what is happening - as a minimum it allows you to describe the error that happened i.e.
throw new Exception('Product price is not set');
Throwing specific exceptions (or custom ones) can provide you with a greater level of detail and can be used within try-catch statements to allow you to do different things depending on which exception was thrown.
throw new ProductPriceNotSetException();
Graceful Execution
As returning null
doesn't provide context, you can only handle it in a generic way i.e. display a "Sorry, an unexpected error occurred" but if we throw exceptions then we can provide context and with that, we can decide to handle things in a variety of different ways.
Let's consider Amazon's 1-click checkout - There could be a variety of things that go wrong as part of that process. If we returned null
for an error we could have something that looks like
/**
* Apply logic to complete 1-click checkout
* various exceptions are thrown depending on what has happened
*/
$success = $basket->performSingleClickCheckout();
if (null === $success) {
// Cool! We've got a `null` back - but what the hell happened?
// ... logic to apply a generic error and redirect the user to the basket
}
But if we use exceptions, we can change what we do based on the exception as we now have context
try {
/**
* Apply logic to complete 1-click checkout
* various exceptions are thrown depending on what has happened
*/
$basket->performSingleClickCheckout();
} catch (ProductNotInStock $e) {
/**
* The product's stock level has changed between them adding it
* to their basket and their attempt to checkout. We could
*
* - redirect the customer to the basket page and show a warning
* - send an email to stock control to get more stock ordered
*
* We can even use $e->getProduct() to get the product (provided we've set the event up for that).
*/
// ... add logic here
} catch (ProductPriceMismatch $e) {
/**
* The product's price has changed between them adding it
* to their basket and their attempt to checkout. We could
*
* - redirect the customer to the basket page and show a warning
*/
// ... add logic here
} catch (CustomerNoShippingAddress $e) {
/**
* The customer has attempted to checkout without having
* supplied a shipping address
*
* - redirect the customer to the shipping page to capture a shipping address
*/
// ... add logic here
}
Note: in the case above I've not caught the default Exception
and this is because I want to handle the cases I except and I want the unexpected to error out so I can capture these issues and identify how to resolve them.
Now I can hear some of you shouting "What about the graceful degradation of your application? If you throw an exception then your users will get a bad experience!"
This is true, they will have a bad experience, however, graceful degradation should always be the aim but not to the point of potentially masking issues - this will cause you more pain and cause your customers more grief in the long run. Would you rather be told "We tried to take a payment and we can see an error with that attempt so we'll go and fix that" or "We tried to take a payment but we don't know why it failed?"
Conclusion
My recommendation here is
- Integrate an error reporting platform like Sentry into your application.
- Throw exceptions for errors in your application
- Wrap any potentially expected exceptions in try/catch statements and handle them appropriately
- Allow your application to throw exceptions for anything unexpected. These will be logged in your error reporting platform and you can be notified of anything critical in real-time - allowing a proactive approach to issues within your application.
- Return
null
only whennull
is a valid return type.
This approach will provide you with the best of both worlds - graceful degradation for expected exceptions and real-time reporting of unexpected ones
Originally published at https://chrisshennan.com/blog/return-null-or-throw-exception-best-practice