Skip to content

Instantly share code, notes, and snippets.

@oliverlambson
Last active September 1, 2024 22:25
Show Gist options
  • Save oliverlambson/6c33ec5f278aa2e68152e1bdaf76b815 to your computer and use it in GitHub Desktop.
Save oliverlambson/6c33ec5f278aa2e68152e1bdaf76b815 to your computer and use it in GitHub Desktop.
Rust & Go-style return types in Python

Rust-style Result type in Python

Lightweight, generic result type à la Rust. (Sometimes you just want errors as values.)

Note: There is a full-on package that does this, with all the other Rust niceties of .unwrap_or(), etc. But if all you need is the basics, this is a zero-dependency approach in like 8 lines of actual code.

from dataclasses import dataclass


@dataclass
class Ok[T]:
    value: T


@dataclass
class Err[E]:
    value: E


type Result[T, E] = Ok[T] | Err[E]


# --- Example -----------------------------------------------------------------
def fn(x: bool) -> Result[int, str]:
    if x:
        return Ok(1)
    else:
        return Err("woah")


y = fn(True)
match y:
    case Ok(value):
        print(value + 1)
    case Err(err):
        print(err.upper())

If you want a few of the quality-of-life features so you con't have to constantly pattern match:

from dataclasses import dataclass
from typing import Literal, Never


class UnwrapError(Exception):
    pass


@dataclass
class Ok[T]:
    value: T

    def unwrap(self) -> T:
        return self.value

    def unwrap_or(self, value: T) -> T:
        return self.value

    def ok(self) -> T:
        return self.value

    def err(self) -> None:
        return None

    def is_ok(self) -> Literal[True]:
        return True

    def is_err(self) -> Literal[False]:
        return False


@dataclass
class Err[T, E]:
    value: E

    def unwrap(self) -> Never:
        raise UnwrapError(self.value)

    def unwrap_or(self, value: T) -> T:
        return value

    def ok(self) -> None:
        return None

    def err(self) -> E:
        return self.value

    def is_ok(self) -> Literal[False]:
        return False

    def is_err(self) -> Literal[True]:
        return True


type Result[T, E] = Ok[T] | Err[T, E]


def fn(x: int) -> Result[int, str]:
    if x > 0:
        return Ok(x)
    else:
        return Err("woah")


assert fn(1).unwrap() == 1
try:
    _ = fn(-1).unwrap()
    raise AssertionError
except UnwrapError:
    pass

assert fn(1).unwrap_or(3) == 1
assert fn(-1).unwrap_or(3) == 3

assert fn(1).ok() == 1
assert fn(-1).ok() is None

assert fn(1).err() is None
assert fn(-1).err() == "woah"

assert fn(1).is_ok() is True
assert fn(-1).is_ok() is False

assert fn(1).is_err() is False
assert fn(-1).is_err() is True

Go-style result, err return type in Python

Lightweight, generic result type à la golang.

mypy isn't able to infer without requiring a redundant assert1. I can't figure out how to get a type safe exact go-style implementation, but there's an option below that delays unpacking of the result tuple.

from typing import TypeVar

from typing_extensions import TypeIs

T = TypeVar("T")
E = TypeVar("E")

Ok = tuple[T, None]
Err = tuple[None, E]
Result = Ok[T] | Err[E]


# --- Example -----------------------------------------------------------------
def fn(x: bool) -> Result[int, str]:
    if x:
        return 1, None
    else:
        return None, "woah"


# unfortunately mypy can't figure this out:
def main_fail() -> None:
    result, err = fn(True)
    if err is not None:
        print(err.upper())
        return
    print(result + 1)  # error: Operator "+" is not supported for "None"


# --- Example (with assert) ---------------------------------------------------
# so we have to assert, which kind of defeats the point...
def main_pass_with_assert() -> None:
    result, err = fn(True)
    if err is not None:
        print(err.upper())
        return
    assert result is not None
    print(result + 1)


# --- Example (with TypeIs) ---------------------------------------------------
# we could delay unpacking and use a TypeIs function, but then we have to unpack
# the tuple in both branches...
#
# (I think it's up to personal preference if you prefer this or the assert approach)
def is_err(result: Result[T, E]) -> TypeIs[Err[E]]:
    _, err = result
    return err is not None


def main_pass_with_typeis() -> None:
    result = fn(True)  # can't unpack or we lose result <-> err type relationship
    if is_err(result):
        _, err = result
        print(err.upper())
        return
    value, _ = result
    print(value + 1)

Footnotes

  1. I think this is due to the limitations of Python's current type system, as described in PEP-724. That PEP was withdrawn, but there's a new one, PEP-742, which has been accepted and created TypeIs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment