Afaan Ashiq

Software Engineering

Returning None in Failure Scenarios

September 24, 2022 3 min read

In this post I will talk about a bugbear of mine that I’ve had over the years. This relates to returning None from functions and methods in Python.


Table of Contents


Returning None by default

Python makes it really easy to return None from a function or a method. In fact that’s the default behaviour.

def get_food_for_dog_breed(dog_breed: str) -> Optional[str]:
    
    if dog_name == "Jackapoo":
        return "Dry dog food"

In the above function, if our dog_breed argument is "Jackapoo" then we return the string Dry dog food.
But if the dog_breed is anything but "Jackapoo" then we will return None, due to the implicit return of None which Python imposes.

This is a feature which often trips people up when they are new to Python, including yours truly!


Being explicit about successes and failures

We can say that our only success scenario here is when the dog_breed argument is "Jackapoo". All other input values to dog_breed can be treated as failure scenarios.

But there is a real issue with our get_food_for_dog_breed function.
Our intentions are quite obscure.

Forgive the upcoming cliché, but software engineering is primarily about communication.
Good quality code always gives the reader a very clear idea as to what the author wanted to convey.

For our failure scenarios, the caller of our get_food_for_dog_breed function will receive None. But what exactly does this mean to them?
Does this mean we don’t support dog food for any other dogs?
The fact that we have to ask these questions is enough to tell us that something is missing.
Our function is ambiguous, and we are currently expecting the caller & our readers to infer the meaning of that result.
This is not ideal as it means we have not been clear about our intentions.

From the caller’s perspective, the only way they can tell whether the operation was unsuccessful is to check the type of the return value.


Raising custom exceptions

We should not be imposing this kind of burden on the caller. This can often lead to subtle bugs that are hard to track down.

Instead, what we can do is raise a custom exception:

class FoodNotAvailableForDogBreedError(Exception):
    ...


def get_food_for_dog_breed(dog_breed: str) -> str:
    
    if dog_breed == "Jackapoo":
        return "Dry dog food"
    
    raise FoodNotAvailableForDogBreedError

Now we are communicating with the caller of our function by raising the new custom exception.
We are being clear about what has happened, and we are showing our intent.
The caller will know straight away that we have hit our failure scenario.
The onus is on them now to treat that how they see fit.

Yes this will mean we might end up with lots of custom exception classes.
But that is arguably an acceptable trade-off if it means we gain the huge benefit of being explicit and clear.