Result → typhon_runtime
Result[T, E], Ok, Err
When Result is used anywhere in the project, a typhon_runtime/result.py (or equivalent) is generated:
from dataclasses import dataclassfrom typing import Generic, TypeVar
T = TypeVar("T")E = TypeVar("E")
@dataclass(slots=True, frozen=True)class Ok(Generic[T]): value: T
# combinators def map(self, f): ... # Ok(f(self.value)) def map_err(self, g): ... # self (passthrough) def and_then(self, f): ... # f(self.value) def or_else(self, h): ... # self (passthrough) # unwrap / query family (v0.13.0) def unwrap(self): ... # self.value def expect(self, msg): ... # self.value def unwrap_or(self, default): ... # self.value def unwrap_or_else(self, f): ... # self.value def ok(self): ... # self.value def err(self): ... # None def is_ok(self): ... # True def is_err(self): ... # False
@dataclass(slots=True, frozen=True)class Err(Generic[E]): error: E
# combinators def map(self, f): ... # self (passthrough) def map_err(self, g): ... # Err(g(self.error)) def and_then(self, f): ... # self (passthrough) def or_else(self, h): ... # h(self.error) # unwrap / query family (v0.13.0) def unwrap(self): ... # raise def expect(self, msg): ... # raise with msg def unwrap_or(self, default): ... # default def unwrap_or_else(self, f): ... # f(self.error) def ok(self): ... # None def err(self): ... # self.error def is_ok(self): ... # False def is_err(self): ... # True
# Result is a type alias:# Result[T, E] = Ok[T] | Err[E]from typhon_runtime import Ok, ErrThe four combinator methods (.map / .map_err / .and_then / .or_else) ship with the generated typhon_runtime/result.py since v0.6.0. Since v0.9.0 the in-process VM (tyc run) also binds them as native methods via NativeFn wrappers that capture the receiver, so Ok(7).and_then(double) works identically under both execution modes.
The eight-method unwrap/query family (.unwrap / .expect / .unwrap_or / .unwrap_or_else / .ok / .err / .is_ok / .is_err) lowers the same way — added to the runtime template in v0.13.0, bound as VM natives for tyc run, and typed by receiver-narrowed checker signatures. There is no special emit shape for them: r.unwrap_or(80) is emitted verbatim as a method call resolved against the runtime Ok / Err class. As of v0.13.0 the method surface is closed, so an unknown method name never reaches emit — it is rejected at check time with tyc::attribute_not_found.
User code imports:
The ? operator
def f() -> Result[int, str]: let n: int = parse(raw)? return Ok(n + 1)def f() -> Ok[int] | Err[str]: _tmp_0 = parse(raw) if isinstance(_tmp_0, Err): return _tmp_0 n: int = _tmp_0.value return Ok(n + 1)- A fresh
_tmp_Nis generated per?site. - The check uses
isinstance(_tmp_N, Err). - On
Err, the whole expression is returned (carrying the originalErrvalue forward). - On
Ok, the value is unwrapped and bound.
No try/except. Stack traces stay clean.
with-chains
def f() -> Result[Report, AppError]: with user = db.find(id)?, perms = check(user)?, report = build(user, perms)?: return Ok(report) else err: log.warn(err) return Err(err)def f() -> Ok[Report] | Err[AppError]: _tmp_0 = db.find(id) if isinstance(_tmp_0, Err): err = _tmp_0.error log.warn(err) return Err(err) user = _tmp_0.value
_tmp_1 = check(user) if isinstance(_tmp_1, Err): err = _tmp_1.error log.warn(err) return Err(err) perms = _tmp_1.value
_tmp_2 = build(user, perms) if isinstance(_tmp_2, Err): err = _tmp_2.error log.warn(err) return Err(err) report = _tmp_2.value
return Ok(report)Each step’s error path runs the same else err: block (the unwrapped error is bound to err).
Without an else err:, the lowering omits the block and uses bare return _tmp_N short-circuits.
Since v0.8.0 every ?-binding’s implicit error path is type-checked against the enclosing function’s declared error type. Since v0.9.0 the explicit else err: return Err(err) form goes through the same check — previously the validation was gated on the synthetic ?-op temp shape, so an else block could silently return an Err whose payload type didn’t match the function’s declared return.
try_result
try_result (v0.15.0) is a prelude name — like Ok / Err / Result, it needs no import in source. It does not desugar into an inline try/except; it is emitted as a plain call to the runtime helper, and tyc build auto-injects the import:
def load(path: str) -> Result[dict[str, str], str]: return try_result(lambda: read_json(path), lambda e: f"invalid JSON: {e}")from typhon_runtime import try_result
def load(path: str) -> Ok[dict[str, str]] | Err[str]: return try_result(lambda: read_json(path), lambda e: f"invalid JSON: {e}")The runtime helper does the work:
# typhon_runtime/result.py (excerpt)def try_result(thunk, on_err=None): try: return Ok(thunk()) except Exception as exc: return Err(exc if on_err is None else on_err(exc))- The mapper is optional. Omit it and the raw exception is carried (
Result[T, Exception]); supply it and the caught exception is mapped (Result[T, E],Einferred from the mapper body). - The checker special-cases
try_result(likeas!):Tis inferred from the thunk body andEfrom the mapper body, and a wrong return annotation still firestyc::type_mismatch— it is a realResult, not anAnyescape hatch. - The VM registers
try_resultas a prelude native fortyc run, materialising the caught exception exactly as anexcept E as e:handler would (solambda e: str(e)works), and enforcing its 1–2-argument arity.
rescue
rescue (v1.0.0-alpha) is the lambda-free spelling of the same boundary. It is pure surface sugar, lowered in tyc-syntax’s preprocessor before the parser runs, so nothing new reaches the emitted Python.
Postfix form — EXPR rescue NAME: ERR becomes try_result(lambda: EXPR, lambda NAME: ERR)?, which then takes the ordinary ? lowering:
def load_port(raw: str) -> Result[int, str]: let n: int = int(raw) rescue e: f"bad port: {e}" return Ok(n)from typhon_runtime import try_resultfrom typhon_runtime import Err as __typhon_Err__
def load_port(raw: str) -> Result[int, str]: __typhon_q_0__ = try_result(lambda: int(raw), lambda e: f"bad port: {e}") if isinstance(__typhon_q_0__, __typhon_Err__): return __typhon_q_0__ n: int = __typhon_q_0__.value return Ok(n)The left operand is found with the same bracket-, string-, and comment-aware scan as! uses, so the two compose (json.loads(t) as! dict[str, str] rescue e: …). It is lowered in statement-tail position only (the last thing on the logical line); a mid-expression rescue is left for the parser to reject.
Block form — rescue NAME: ERR: over a suite becomes a real try / except, returning an Err:
def load_config(text: str) -> Result[Config, str]: rescue e: f"bad config: {e}": let data: dict[str, str] = json.loads(text) as! dict[str, str] return Ok(Config(host=data["host"], port=int(data["port"])))from typhon_runtime.cast import checked_cast as __typhon_checked_cast__
def load_config(text: str) -> Result[Config, str]: try: data: dict[str, str] = __typhon_checked_cast__(json.loads(text), dict[str, str]) return Ok(Config(host=data["host"], port=int(data["port"]))) except Exception as e: return Err(f"bad config: {e}")Because the block form emits a literal return Err(...), the checker’s ordinary error-type check applies to ERR in both forms (tyc::result_error_mismatch when it does not fit the function’s declared E). Both forms behave identically under tyc check, tyc run, and tyc build + CPython, and tyc fmt round-trips them. See the reference.
Why a separate runtime module?
Two reasons:
- The dataclasses need a single canonical definition. Generating them inline per-module would explode the output tree.
isinstanceneeds to compare against a single class. Each module’sOk/Errmust be the same class forisinstancechecks to work — a sharedtyphon_runtime.resultensures that.
The module is small (~50 lines), generated, and lives in your build/ tree alongside your code. There is no PyPI install required.
Where next
Result, Ok, Errreference — the type and its constructors.- The ? Operator reference — every rule.
with-chains reference — chained Results.- The typhon_runtime module — what’s in the helper module.