result_py

A Result type much like Rust's, featuring generics and lovely combinators.


Keywords
rust, combinator, error-handling, functional-programming, python, result, type
License
MIT
Install
pip install result_py==1.1.0

Documentation

Result.py

A Result type much like Rust's, featuring generics and lovely combinators.

Error Handling with Result

The Result type is meant to be used to facilitate error handling. This type is designed to provide as similar an interface as possible to that of Rust's Result type. The motivation behind this approach is reasonably summarized by saying that the use of a Result type rather than exceptions makes it easier to reason about code, due to the complexity caused by the way that exceptions affect a program's control flow. The Result type's methods make it easy to compose actions together.

Representing successful and failed computations

This module exports two constructor functions for producing instances of Result. The Ok constructor takes a single value and returns a Result containing that value, indicating the successful completion of a computation. The Err constructor takes a single value which must be either an instance of Exception or a class that inherits Exception, such as TypeError, RuntimeError, OSError, etc. Before considering subclassing Exception yourself, take a moment to see if there is a built-in Exception that meets your needs.

from result import Ok, Err

successful_comp = Ok(32)
print(successful_comp.is_ok())
print(successful_comp.ok())

error_comp = Err(RuntimeError('Something bad happened.'))
print(error_comp.is_err())
print(error_comp.err())

## Output ##
# True
# 32
# True
# Something bad happened.

Also note that, since Err values are literally just instances of exceptions, if you ever find yourself in a situation where you need to throw an exception, you can still do that.

my_error = Err(RuntimeError('Made-up error'))
raise my_error.err()

Handling Errors

As seen in the above section, Result provides two methods-- result.is_ok() and result.is_err()-- that we can use to determine whether a computation succeed or failed. The result.ok() and result.err() methods are used to unwrap the underlying value in their respective cases. Note that if result.is_ok() is True and result.err() is called, None will be returned. The same is true when result.is_err() is True and result.ok() is called.

result.ok() and result.err() enable us to unwrap values from their Result container to handle them explicitly. It is important to note that unwrapping a Result like this makes it impossible to chain further operations on the Result using Result's other convenient methods, so this should only be done at the time at which one is ready to consume the value of a Result and either handle the error or value.

The rest of this document will explore each of Result's other methods, providing examples of the kind of scenarios where each should be used. In general, since most functions are not going to want to unwrap and handle errors themselves, but rather propagate those errors until they reach a part of the program that can handle those errors, the methods discussed from here should be strongly preferred to unwrapping Results.

Result[T, E] Methods

From this point on, the type variable T will refer to the generic type parameter that is contained by a Result created by the Ok constructor function, and E wil refer to the Exception type parameter contained by a Result created by the Err constructor.

result.is_ok(self) -> bool

Determines if a given Result represents a successful computation or not. If the Result was created by calling Ok, this method will return True. Otherwise, it returns False. This method should be called before unwrapping a Result to ensure that we'll end up dealing with the correct value.

if my_result.is_ok():
    handle_ok_value(my_result.ok())
else:
    handle_err(my_result.err())

result.is_err(self) -> bool

This method determines if a given Result represents a failed computation. If the Result was created by calling Err, this method will return True. Otherwise, it returns False.

result.ok(self) -> Optional[T]

Unwraps the underlying value of the Result, assuming it represents a successful computation. If the Result represents a failed computation, None will be returned. However, since None is a valid type for T to take, this method returning None does not necessarily imply that result.is_err() is True.

result.err(self) -> Optional[E]

Unwraps the underlying error contained in a Result, assuming it represents a failed computation. If this method returns None and your code type checks correctly, then this suggests that result.is_ok() is True, since E should be either Exception or a subclass thereof.

result.map(self, f: Callable[[T], U]) -> Result[U, E]

Apply a function to transform the value contained by a Result representing a successful computation. Its argument is a function that takes a value of type T and returns a new value of some type U (which could practically be the same as T). If the result contains Ok(value), the function will be applied to that underlying value, leaving us with a Result containing Ok(f(value)). This method should be used any time we want to transform a value contained by a Result rather than, for example, returning that value. Any time you might write something like the following:

my_result = thing_that_might_fail()
if my_result.is_ok():
    good_value = my_result.ok()
    return Ok(transformation(good_value))
else:
    return my_result

you almost certainly should, instead, write:

return thing_that_might_fail().map(transformation)

result.map_err(self, f: Callable[[E], F]) -> Result[T, F]

Apply a function to transform the error contained by a Result representing a failed computation. Its argument is a function that takes an exception instance of type E and returns a new exception instance of some type F (which could be the same as E). If the result contains Err(error), the function will be applied to the underlying error leaving us with a Result containing Err(f(error)). The use case is similar to result.map.

A common practical example of where result.map_err is used is for transforming an error type in order to achieve consistency with a function's type signature. Suppose we had two functions with the following signatures:

http_post(url: str, data: Dict[str, Any]) -> Result[Response, ConnectionError]
upload_results(results: List[Dict[str, Any]]) -> Result[UploadResponse, C2Error]

If upload_results wanted to call http_post and return its result after converting the Response type into an UploadResponse, we would get a type error because upload_results expects an error type of C2Error, not ConnectionError. This issue can be remedied with a call to result.map_err.

def upload_results(results: List[Dict[str, Any]]) -> Result[UploadResponse, C2Error]:
    return http_post(C2_ADDRESS() + '/results', {'results': results})\
        .map_err(lambda conn_err: C2Error('Failed to upload results.', cause=conn_err))\
        .map(lambda response: UploadResponse(**response.json()))

result.and_then(self, f: Callable[[T], Result[U, E]]) -> Result[U, E]

Apply a function which itself returns a Result to the value contained in Ok(value), and return a new Result produced by the evaluation of f(value). Whereas result.map is used to transform values using functions that do not return Results (i.e. cannot fail), result.and_then is used to chain operations that can fail, negating the need to unwrap values. Consider the following example.

return detect_env_proxy(env_variable_name)\
    .and_then(lambda proxy: make_http_client(C2_ADDRESS(), proxy))\
    .and_then(lambda request_fn: cmdctrl.authenticate(request_fn, api_key, secret_key))

result.or_else(self, f: Callable[[E], Result[T, F]]) -> Result[T, F]

Apply a function which itself returns a Result to the error contained in Err(error), and return a new Result produced by the evaluation of f(error). Whereas result.map_err is used to transform values using function that do not return Results (i.e. cannot fail), result.or_else is used to chain operations that can fail, negating the need to unwrap errors. result.or_else is useful to call in cases where we want to try to recover from an error. Consider the following example which, failing to detect a system-configured proxy, attempts to recover by detecting a usable proxy given by an environment variable.

return detect_system_proxy()\
    .or_else(lambda _: detect_env_proxy(env_variable_name))\
    .and_then(lambda proxy: make_http_client(C2_ADDRES(), proxy))\
    .and_then(lambda request_fn: cmdctrl.authenticate(request_fn, api_key, secret_key))

Note that, because result.or_else calls a function to transform a contained error, it is suitable for use in cases where we only want to call a function if a former computation failed, resulting in extra work being done only if a failure occurs.

result.conjunct(self, res: Result[U, E]) -> Result[U, E]

Since Python won't let us name methods or functions using reserved keywords, we had to forego calling this method result.and, the way Rust does, and instead used a shortened form of the word "conjunction", meaning effectively the same thing. result.conjunct behaves similarly to how the logical operator and does. Consider the following code:

[1, 2, 3] and 8
# >>> 8

The above expression evaluates to 8 becaue Python treats both values as "truthy," and the and operator tests that both of its operands are truthy, evaluating to the latter of the two. result.conjunct behaves in much the same way, such that

first_result.conjunct(second_result)

returns second_result if first_result contains an Ok value. Otherwise, it returns first_result, which will contain an Err error. result.conjunct should be used when you want to verify that one computation succeeded and, if it did, get the result of a second computation, or else handle the first error encountered. Suppose you were parsing some input that could parse to one of two possible abstract syntax trees, with one receiving preference over the other. You might write the following code to handle such a situation:

tree1 = parse_tree1(_input)
tree2 = parse_tree2(_input)
tree1\
    .conjunct(tree2)\
    .and_then(process_ast)

result.disjunct(self, res: Result[U, E]) -> Result[U, E]

result.disjunct behaves very similarly to result.conjunct except that, instead of behaving like the logical and operator, it behaves like logical or. In other words, much like how the following code will evaluate to the first of the two if it is "truthy" or else the second, result.disjunct will similarly return the first result if it contains an Ok value, or else the second result.

None or 32
# >>> 32

Useful Patterns

The Result type's methods lend themselves very well to a functional style of programming and, following from that, makes some pretty elegant patterns possible.

Taking the last Ok value or first Err of a list of results

Suppose that you had a list of Results and wanted to either take the last Ok result or the first Err in the list. This would be similar to calling Python's all function on a list of boolean values. You could write code such as the following to accomplish this.

results = [Ok(32), Ok(101), Err(ValueError('not an int')), Ok(-2)]
last = reduce(lambda r1, r2: r2.conjunct(r2), results)
print(last.err())
# >>> not an int

results = [Ok(32), Ok(101), Ok(-2)]
last = reduce(lambda r1, r2: r1.conjunct(r2), results)
print(last.ok())
# >>> -2

Taking the first Ok value of a list of results

Suppose that you had a list of Results and you wanted to take the first result that has an Ok value in it. This might be very similar to calling Python's any function on a list of boolean values. You could write code such as the following to accomplish this.

results = [Ok(32), Ok(101), Err(ValueError('not an int')), Ok(-2)]
first = reduce(lambda r1, r2: r1.disjunct(r2), results)

print(first.ok())
# >>> 32

Converting a list of results to a result of a list

JavaScript's Promise type has a static method, Promise.all that takes a list of promises and returns a promise containing a list of each promise's resolved value, or an error if any of those promises reject. We can do something similar with Results, converting a list of Results into a Result containing a list of Ok values or else the first Err in the list. Warning. Black magic ahead.

results = [Ok(32), Ok(101), Err(ValueError('not an int')), Ok(-2)]
ls_result = reduce(lambda r1, r2: r1.and_then(lambda ls: r2.map(lambda x: ls + [x])), results, Ok([]))
print(ls_result.err())
# >>> not an int

results = [Ok(32), Ok(101), Ok(-2)]
ls_result = reduce(lambda r1, r2: r1.and_then(lambda ls: r2.map(lambda x: ls + [x])), results, Ok([]))
print(ls_result.ok())
# >>> [32, 101, -2]