Build a Simple Result type in Python - and why you should use them

Date: 2024-06-12 | create | tech | python | types |

Types are like labels for code paths. They define explicit contracts for what a flow expects and what you can expect in return.

  • Done well - these labels make code easier to understand and maintain at scale - over time, people, and lines of code
  • Done poorly - they add little information or in extreme cases make it harder to understand

The key differentiators between the two are usually:

  • Honesty - Is this really the input / outputs? If you say string but it can be null -> breakage.
  • Precision - Is this narrowing possibilities? If you say Any but it's only useful for strings then this isn't really helping.

Python has type hints but is still missing a lot of features I would expect of "modern types". Moreover a lot of documentation is still written in a dynamic fashion - without types at all.

In this post we're going to explore what a Result type is, why it's helpful, and how to write one in Python.

Types make contracts explicit

I believe clear, honest types make code easier to understand and maintain at scale. They make the contract of what they support explicit which allows editors / compilers / code tools surface contract breakage at code time so you can fix it before you ship the broken thing to prod.

The main argument against types is that they are clunky, lie, and get in the way. I think this is true for bad type systems or when misusing good type systems but generally type systems are THE way to declare contracts so when done well allow for simple, precise contract declaration and thus can avoid these downsides.

Python does not have a very good type system IMO but we can leverage the constructs it provides to approximate better type systems.

Result Types and why they matter

Most logic is CRUD.

  • Get data / make a decision
  • Do something with that

Each of these will often return something to signal what happened:

  • Was it successful?
  • If not successful, why not?
  • Any other infos you might want - like an ID on success or an error code to show to the user or smth like that.

Typically we start off with a very rudimentary return type and as usecases grow in complexity we add more. A common evolutionary pattern for these return types is like this:

  • None - start off with no return cause not needed right now. Maybe it throws hard on errors.
  • bool - We have cases where throwing hard doesn't make sense (like user sent an invalid value). Bool represents whether successful or not.
  • (bool, None | str) - Okay now we want to return why we failed so that callers can recover or surface it to the user (like what field did you set wrong).
  • (bool, None | str[]) - Okay but now we have multiple fields that could be wrong and we want to surface all the bad ones directly to the user so they can fix all of them (like for a form on a webpage).

These patterns are probably familiar. You probably have versions of these in your own code. They are pretty common in Python and in some languages are the recommended pattern (cough Golang cough). They are fine and they can work okay but they are reliant on a lot of implicit logic - if bool = False then str is not None.

For simple cases this is okay maybe but often our system wants to keep evolving.

So what if we want to return an ID on success? How does that work in our current system?

We might do something like this:

(bool, None | str, None | str[])

Given the context of the evolution above maybe we can infer what this means (for reference this is (success, id, errors)). But would a random new dev coming in be able to intuit what this means? Probably not.

And worse nothing is stopping them from doing smth like (success, error, ids) simply cause they didn't fully understand what the contract was.

At this point you might consider writing your own dataclass to model this and make the values more explicit. This is better but still does not enforce that id only exists on true and errors only on false. You could of course go OO factory methods like create_success and create_err which is pretty good actually for data creation but still does not help downstream readers / type system knowing what data is supposed to be filled in on success or failure.

class SuccessOrErr:
    success: bool
    id: None | str 
    errors: None | str[]

Result types try to make this common scenario a bit easier. Basically they say a Result can be:

  • Ok -> TOk
  • Err -> TErr

This makes it easier for the type system to infer what type to expect in a success vs failure case and thus it can help ensure the contract is respected - both at creation time and at read time.

Here we could model our return type like:

Result[str, str[]]

if result.is_ok:
    result.value -> str
if !result.is_ok:
    result.value -> str[]

No null pointer exceptions cause the type system KNOWS and ENFORCES what type is what based on the context of success vs failure. Amazing! Honest! Precise!

Building a simple Result type in Python

First off you might not want to build your own result type. Rolling your own stuff can be fun and adds customizability but often it's better to just use a battle-hardened one from the community and move on with your life. I personally like rustedpy/result so consider just using that if you want.

That said there are some times where pulling in 3rd party libraries to do things doesn't make sense. Maybe 3rd party libs are not allowed or maybe there's a lengthy approval process or maybe you just don't want the baggage that goes along with it. There are many reasons why this might be true for your case and this is what happened in my case as well so here we'll go into a very simple version of a Result type that you can copy / paste w/o the baggage.

This example is available on Replit if you want to play with it: (Replit)

from typing import TypeAlias, TypeVar, Generic

"""
This is based off of: https://github.com/rustedpy/result
"""

TOK = TypeVar("TOK")
TERR = TypeVar("TERR")


class Ok(Generic[TOK]):
    _value: TOK

    def __init__(self, value: TOK):
        self._value = value

    def is_ok(self) -> bool:
        return True

    def ok_value(self) -> TOK:
        return self._value


class Err(Generic[TERR]):
    _err: TERR

    def __init__(self, err: TERR):
        self._err = err

    def is_ok(self) -> bool:
        return False

    def err_value(self) -> TERR:
        return self._err


SimpleResult: TypeAlias = Ok[TOK] | Err[TERR]

# Tests

def create_result(should_be_ok: bool, message: str) -> SimpleResult[str, str]:
    if should_be_ok:
        return Ok(message)
    else:
        return Err(message)

def test_okay():

    # Arrange

    print("Test: Okay")
    
    OKAY_MESSAGE = "I am okay!"

    # Act
    
    ok_val = create_result(True, OKAY_MESSAGE)

    # Assert
    
    assert isinstance(ok_val, Ok)
    assert not isinstance(ok_val, Err)
    assert ok_val.is_ok() == True
    assert ok_val.ok_value() == OKAY_MESSAGE

    print(f"* ok_val: {ok_val}")
    print(f"* ok_val.ok_value(): {ok_val.ok_value()}")

def test_err():
    # Arrange

    print("Test 2: Err")
    ERR_MESSAGE = "I am not okay!"

    # Act
    
    err_val = create_result(False, ERR_MESSAGE)

    # Assert
    
    assert isinstance(err_val, Err)
    assert not isinstance(err_val, Ok)
    assert err_val.is_ok() == False
    assert err_val.err_value() == ERR_MESSAGE

    print(f"* err_val: {err_val}")
    print(f"* err_val.err_value(): {err_val.err_value()}")

test_okay()
test_err()

In this code we:

  • Create two TypeVars so that python hinter can infer the types we're using and thus enforce contracts - TOk for Okay case and TErr for Err case
  • Create Ok and Err classes which are similar in form. Crucially they use the generic TOk and TErr types so that python hinter can understand these types and enforce contract
  • Create a SimpleResult which is either an Ok or Err, including the generic TOk and TErr types. This gives us a simple way to declare these and have the types flow through to our code.
  • Finally some tests to show you how these work. And yes these are typesafe! You can check it out in the Replit to see that it knows what the types should be on each.

Next

Python still doesn't have a very good type system but there are ways to make it just a bit nicer to work with. Hopefully this helps you make your contracts simpler and more precise to make coding easier, more fun, and w less bugs.

I was introduced to the world of modern, precise types via F#. It does things differently than most languages but I think it reveals a lot about what programming could be and how a lot of languages are not living up to their potential. If you're interested in learning about F#, here's some resources to get you started.

Q: How are you representing success / failure in your Python code?

If you liked this post you might also like:

Want more like this?

The best / easiest way to support my work is by subscribing for future updates and sharing with your network.