Exceptions and Results

abstractions to wrap exceptional actions

Python

In working with Python and Django, Exceptions can come out of nowhere. Borrowing a simple application domain from django-zen-queries, assume the following basic pizza shop model:

class Topping(models.Model):
    name = models.CharField(max_length=100)


class Pizza(models.Model):
    name = models.CharField(max_length=100)
    toppings = models.ManyToManyField(Topping)

Django has some wonderful utilities for getting an item and returning a view like get_model_or_404. This works great within a large or function based view, but we usually use services for some of our more complex logic, and going from Model -> HttpResponse is a little too quick, where our services are typically nested inside our views and are fallible.

So, we need an intermediate type. Our services are fallible, so they can be one of two things: Ok(val), with some wrapped return value, or an Error, abbreviated Err(err), with one of many possible Errors specific to our service and it's underlying details.

In the case of our friend .get on a model item's default manager, .objects this looks like:

Pizza.objects.get(name="Cheese")

But, this can raise an exception! Specifically, there are two exceptions, provided we passed a correct filter (Q('name=="Cheese"')). The two exceptions that can be raised by this code path are ObjectDoesNotExist, and MultipleObjectsReturned.

Enter exception handling:

try:
  pizza = Pizza.objects.get(name="Cheese")
except Pizza.DoesNotExist:
  # do something else
  ...
except Pizza.MultipleObjectsReturned:
  # Also do something else
  ...

Enter another dependent model lookup, and you'll start writing the following:

try:
  pizza = Pizza.objects.get(name="Cheese")
  try:
    pizza.toppings_set.get(name="Cheese")
  except Topping.DoesNotExist:
    ...
  except Topping.MultipleObjectsReturned:
    ...
except Pizza.DoesNotExist:
  # do something else
  ...
except Pizza.MultipleObjectsReturned:
  # Also do something else
  ...

Pretty verbose, and definitely explicit! I wonder if there is a type that could wrap this logic safely, and give the user a more pattern matching type approach as seen from our colleagues in the rust and functional world...

Enter result. It's got an API modeled after Rust's Result Type, and has a covariant and contravariant type constructor (thats happy path and sad path respectively for the mere mortals). When combined with case match from newer python versions, and structural unpacking (which you can read about here), handling errors can remain explicit. It can also be much less verbose, and more fluent and compact. Assume a function for our result type:

from typing import TypeVar, Type
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models.base import Model
from result import Result, as_result

TM = TypeVar("TM", bound=Model)
def get_model_item_safe(
    model: Type[TM], *args, **kwargs
) -> Result[TM, ObjectDoesNotExist | MultipleObjectsReturned]:
    @as_result(ObjectDoesNotExist, MultipleObjectsReturned)
    def _get_model_item_safe(model: Type[TM], *args, **kwargs) -> TM:
        return model.objects.get(*args, **kwargs)

    return _get_model_item_safe(model, *args, **kwargs)

We can then use it as follows:

pizza = get_model_item_safe(Pizza, name="Cheese")
match pizza:
  case Ok(found_pizza):
    # Handle your happy path :) !
    return found_pizza
  case Err(err):
    # Handle your sad path :( !
    ...

Hopefully that inspires you to take a look at result, and exploring how types can make your dev experience a bit more managed and explicit.