03.07 — Unions discriminées + TypeGuard + @final = unions « sealed »#

Le combo qui rend Result[T] et TestResult à la fois typés et utilisables sans cast.

Union discriminée#

@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
  • Deux constructeurs : Ok[T] et Fail.
  • Le discriminant est l’isinstance check (pas un champ comme tag: Literal["ok", "fail"]).
  • Les deux sont @final : aucun sous-type possible. → l’union est exhaustive, donc « sealed ».

Narrowing, TypeGuard#

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)
def handle_result(result: Result[int]) -> str:
    if is_ok(result):
        return f"Got: {result.value}"          # mypy : result est Ok[int], donc .value existe
    if is_fail(result):
        return f"Error: {result.error}"        # mypy : result est Fail, donc .error existe
    # mypy peut détecter que cette branche est inatteignable (exhaustivité)

_BaseResult#

class _BaseResult:
    error: Exception | None

Cette base permet d’accéder à result.error sans narrower :

def maybe_log_error(result: Result[int]) -> None:
    if result.error:                            # ok, _BaseResult a "error"
        logger.error(str(result.error))

Mais : sans narrowing, on ne peut pas accéder à result.value (qui n’existe que sur Ok).

TestResult = Result[Any] | None#

# src/ocarina/custom_types/oc_test_layers.py
type TestResult = Result[Any] | None

Trois cas : Ok, Fail, None (skip). Trois TypeGuard :

# src/ocarina/aggregates/tests_layers.py
def is_test_result_ok(result: TestResult) -> TypeGuard[Ok[Any]]:
    return isinstance(result, Ok)

def is_test_result_fail(result: TestResult) -> TypeGuard[Fail]:
    return isinstance(result, Fail)

def is_test_result_skipped(result: TestResult) -> TypeGuard[None]:
    return result is None

Dans pretty_print_results (extrait) :

for test_name, (result, steps, test_id) in suite_results.items():
    if is_test_result_skipped(result):
        print(f"  > {test_name} » SKIPPED")
    elif is_test_result_ok(result):
        print(f"  > {test_name} » PASSED")
    elif is_test_result_fail(result):
        print(f"  > {test_name} » FAILED")
        print(f"    → {result.error}")
        print(f"      ⫸ At step {steps}")

Chaque branche est typée. result.error est Exception après is_test_result_fail.

@final dans le framework#

ClasseFichier
Ok[T], Failrailway/result.py
Test[Driver]dsl/testing/oc_test.py
Watcher[Driver]dsl/testing/watcher.py
TestCycle[Driver]dsl/testing/oc_test_cycle.py
ChainRunner[T]dsl/testing_with_railway/chain_actions.py
ActionStart[T], ActionFailure[T], ActionSuccess[T], ActionChain[T]dsl/testing_with_railway/internals/action_chain.py
NeutralActionStart[T], NeutralActionFailure[T], NeutralActionSuccess[T]id.
Whendsl/testing_with_railway/match_page.py
_PredicateWithMsg, _ValidationChain, _ValidationResult, ValidationStartBlock, ValidationAssertBlock, BusinessInvariantValidator, FrameworkInvariantValidatordsl/invariants/{validate.py,internals/validation_chain.py}
ExecutionOutcome, TestExecutor[Driver], TestFlow[Driver]dsl/testing/internals/{test_executor,test_flow}.py
WebDriversPool[Driver]infra/drivers_pool.py
DriverDiedErrorcustom_errors/test_framework/driver_died.py
Scenario[Driver], TestRunner[Driver]custom_types/{scenario,test_runner}.py
FileLoggeropinionated/loggers/file_logger.py

Rien n’est sous-classable dans le DSL. Toute extension passe par composition (closures, adapters projet, valeurs paramétrées).

Le bénéfice (sécurité de type, exhaustivité, refactor-safety) est immense.

Le compilateur fait foi#

Le Holy Book le dit :

Ocarina rend son mésusage difficile par conception : le compilateur fait foi.

Ici, « le compilateur fait foi » se matérialise dans :

  • Result[T]
  • TypeGuard
  • @final