Date: 2024.06.12 | create | python | tech | types |
DISCLOSURE: If you buy through affiliate links, I may earn a small commission. (disclosures)
Types are like labels for code paths. They define explicit contracts for what a flow expects and what you can expect in return.
The key differentiators between the two are usually:
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.
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.
Most logic is CRUD.
Each of these will often return something to signal what happened:
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:
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:
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!
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:
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:
The best way to support my work is to like / comment / share for the algorithm and subscribe for future updates.