02.03.01 — The Result[T] type#
Source file:
src/ocarina/railway/result.py— zero dependencies.
Code#
from dataclasses import dataclass, field
from typing import TypeGuard, final
class _BaseResult:
"""Base class ensuring both Ok and Fail have error attribute for type narrowing."""
error: Exception | None
@final
@dataclass(frozen=True)
class Ok[T](_BaseResult):
value: T
error: None = None
@final
@dataclass(frozen=True)
class Fail(_BaseResult):
error: Exception = field(default_factory=lambda: Exception("Unknown error"))
type Result[T] = Ok[T] | Fail
def is_ok[T](result: Result[T]) -> TypeGuard[Ok[T]]:
return isinstance(result, Ok)
def is_fail[T](result: Result[T]) -> TypeGuard[Fail]:
return isinstance(result, Fail)Six design decisions, dissected#
1. _BaseResult as a common parent#
class _BaseResult:
error: Exception | NoneOne job: let mypy read result.error without narrowing first. Without the base, you’d have to write:
if is_fail(result):
print(result.error) # ok, narrowed…every single time. With the shared base, you can sometimes go:
print(result.error or "no error")…and the checker waves it through because both classes expose error. Ergonomic shortcut, not a license to skip narrowing — the value can be None (Ok), so 99% of the time you should narrow first.
2. @final everywhere#
@final on Ok[T] and Fail (and on ActionStart, ActionChain, etc. down the line).
- No user inheritance —
class MyOk(Ok[T])is a mypy error. - The type-checker treats
Result[T] = Ok[T] | Failas a closed (“sealed”) union. isinstanceexhaustiveness is locked in.
3. @dataclass(frozen=True)#
- Immutable:
Ok(value=42).value = 43raisesFrozenInstanceError. - Auto-generates
__init__,__repr__,__eq__,__hash__(frozen). Ok(42) == Ok(42)just works.
4. Fail has a default_factory#
error: Exception = field(default_factory=lambda: Exception("Unknown error"))So Fail() works with no arguments.
5. type Result[T] = Ok[T] | Fail (PEP 695)#
PEP 695 syntax, introduced in Python 3.12:
type Result[T] = Ok[T] | FailLegacy equivalent:
from typing import TypeAlias, TypeVar, Union
T = TypeVar("T")
Result: TypeAlias = Union[Ok[T], Fail]PEP 695 wins:
- Readable, no import.
Tdoesn’t leak into module scope.- Sharper type-checking.
6. TypeGuard[Ok[T]] / TypeGuard[Fail]#
def is_ok[T](result: Result[T]) -> TypeGuard[Ok[T]]:
return isinstance(result, Ok)Without TypeGuard:
def is_ok(result): return isinstance(result, Ok)
r = some_action()
if is_ok(r):
print(r.value) # mypy doesn't know r is Ok[T] → errorWith TypeGuard[Ok[T]], mypy narrows r to Ok[T] inside the True branch. This is the bedrock for typed Result usage everywhere else in the framework. aggregates/tests_layers.py mirrors the pattern for TestResult:
def is_test_result_ok(result: TestResult) -> TypeGuard[Ok[Any]]: ...
def is_test_result_fail(result: TestResult) -> TypeGuard[Fail]: ...
def is_test_result_skipped(result: TestResult) -> TypeGuard[None]: ...Usage examples (from the framework)#
Direct construction#
return Ok(value=42)
return Fail(error=RuntimeError("nope"))
return Fail() # error = Exception("Unknown error")Discrimination#
result = run_action()
if is_fail(result):
failure_handler(result.error)
return ActionChain(has_failed=True, result=result)
success_handler()
return ActionChain(has_failed=False, result=result)Converting an exception into Fail#
def run_action() -> Result[TPOM]:
try:
result_pom = action(pom)
return Ok(result_pom)
except Exception as exc: # noqa: BLE001
if on_failure:
return on_failure(pom, exc)
return Fail(error=exc)Excerpt from create_act. This is the only place that turns an exception into a Fail. Past this point, nothing else has to repeat that conversion.
Related tests#
tests/scenarios/test_railway_and_action_chain.py opens with a direct hit on these discriminators:
@allure.title("is_ok and is_fail discriminate Ok from Fail")
def test_result_discriminators_split_ok_and_fail() -> None:
ok: Ok[int] = Ok(value=42)
fail = Fail(error=RuntimeError("x"))
assert is_ok(ok)
assert not is_fail(ok)
assert is_fail(fail)
assert not is_ok(fail)And tests/dsl/testing_with_railway/test_types.yml (see ../../04-internal-tests/04-mypy-plugins-types.md) confirms reveal_type(...) returns ActionStart[<Page>] after an act(...). The typing is tested.