Result, Ok, Err
Result[T, E] is a sealed sum with two constructors, Ok(T) and Err(E). It is emitted as a tagged dataclass in a generated typhon_runtime module — no PyPI dependency.
Spelling
def parse(raw: str) -> Result[int, str]: if raw.isdigit(): return Ok(int(raw)) return Err(f"not a number: {raw}")Result[T, E] is a sealed alias for Ok[T] | Err[E]. The two constructors are the only inhabitants.
Constructors
Ok(value) # T inferred from valueErr(error) # E inferred from errorOk[int](42) # explicit TErr[str]("oops") # explicit EOk and Err are generic over their payload types.
Pattern matching
match on a Result must cover both Ok and Err:
match parse("42"): case Ok(n): print(f"got {n}") case Err(msg): print(f"error: {msg}")The checker enforces exhaustiveness over the two variants.
The ? operator
? suffix on a Result-typed expression unwraps Ok and short-circuits Err:
def f() -> Result[int, str]: let n: int = parse("42")? # short-circuits Err to enclosing return return Ok(n + 1)See The ? Operator for every rule.
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)See with-chains.
What is emitted
A generated typhon_runtime.py module is written into the output tree when Result is used:
# build/typhon_runtime.py (excerpt)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
@dataclass(slots=True, frozen=True)class Err(Generic[E]): error: E
Result = Ok[T] | Err[E]Not a PyPI package. The module lives in your project’s build/ tree and ships with your wheel.
Combinators
Ok and Err expose four combinator methods on the runtime classes:
| Method | Receiver | Semantics |
|---|---|---|
.map(f) | Ok(v) | Ok(f(v)) — transforms the inner value |
.map(f) | Err(e) | Err(e) — pass-through |
.map_err(g) | Err(e) | Err(g(e)) — converts the error type |
.map_err(g) | Ok(v) | Ok(v) — pass-through |
.and_then(f) | Ok(v) | f(v) — chains a Result-returning op |
.and_then(f) | Err(e) | Err(e) — pass-through |
.or_else(h) | Err(e) | h(e) — recovers from the error |
.or_else(h) | Ok(v) | Ok(v) — pass-through |
let toks: Tokens = tokenize(src).map_err(_lex_to_pipeline)?let ast: Ast = parse(toks).map_err(_parse_to_pipeline)?let ty: TypedAst = check(ast).map_err(_type_to_pipeline)?The combinators land in v0.6.0 and work under tyc build && python build/main.py from that release. Since v0.9.0 they also work under tyc run — the VM binds them as native methods via NativeFn wrappers that capture the receiver, so a typecheck-clean program no longer crashes with AttributeError: Ok has no attribute 'and_then' under tyc run.
Unwrap / query family
The combinators above stay inside the Result world. The unwrap/query family — added in v0.13.0 — is how you ask a Result a yes/no question, pull a default out of it, or deliberately escape Result back into a plain value (or a T?). These eight methods join the combinators on Ok / Err / Result:
| Method | Signature | On Ok(v) | On Err(e) |
|---|---|---|---|
.unwrap() | () -> T | v | raises |
.expect(msg) | (str) -> T | v | raises with msg |
.unwrap_or(default) | (T) -> T | v | default |
.unwrap_or_else(f) | ((E) -> T) -> T | v | f(e) |
.ok() | () -> T? | v | None |
.err() | () -> E? | None | e |
.is_ok() | () -> bool | True | False |
.is_err() | () -> bool | False | True |
let port: int = parse("8080").unwrap_or(80) # 80 on Err, never raiseslet n: int = parse(raw).expect("config must hold a numeric port")let maybe: int? = parse(raw).ok() # None on Errif parse(raw).is_ok(): ...Like the combinators, these are backed by the runtime template in the generated typhon_runtime/result.py, by VM natives (so they work identically under tyc run), and by receiver-narrowed checker signatures — calling .ok() on an expression the checker has narrowed to Ok[T] is typed as T?, and so on. Added in v0.13.0.
Bridging a boundary with try_result
try_result(thunk[, on_err]) — added in v0.15.0 — turns a throwing boundary call into a Result in one expression, instead of a hand-written try: return Ok(x) except E as e: return Err(...):
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) -> Result[dict[str, str], str]: return try_result(lambda: read_json(path), lambda e: f"invalid JSON: {e}")It runs thunk() and returns Ok(result); on any exception it returns Err(on_err(exc)). Omit the mapper and it returns Err(exc) — the raw exception — giving Result[T, Exception]:
let r: Result[dict, Exception] = try_result(lambda: read_json(path))- Prelude name. No import needed in source — exactly like
Ok/Err/Result.tyc buildauto-injectsfrom typhon_runtime import try_result(visible in the Emitted Python tab above), and the VM registers it as a prelude native fortyc run. - Typed
Result[T, E], notAny.Tis inferred from the thunk body,Efrom the mapper body (Exceptionwhen the mapper is omitted). It is special-cased in the checker (likeas!), so a wrong return annotation still firestyc::type_mismatch—try_resultis a genuineResult, never an escape hatch. - Faithful under
tyc run. The VM materialises the caught exception exactly as anexcept E as e:handler would, solambda e: str(e)works, and it enforces the 1–2-argument arity.
Common patterns
Lifting an exception into Result
import json
def load(path: str) -> Result[dict, str]: try: with open(path) as f: return Ok(json.load(f)) except FileNotFoundError: return Err(f"not found: {path}") except json.JSONDecodeError as e: return Err(f"invalid JSON: {e}")The combination of open() write/append/binary modes (v0.9.0), json.load / json.dump riding on top, and the with-block honouring __enter__ / __exit__ means the same source runs identically under tyc run and the compiled path.
When the boundary is a one-off — a single library call whose exception you want as an Err — try_result (v0.15.0) collapses the whole try/except into one expression, e.g. try_result(lambda: read_json(path), lambda e: f"invalid JSON: {e}"). See Bridging a boundary with try_result above for the full rules.
Mapping over Ok
def parse_and_double(raw: str) -> Result[int, str]: return parse(raw).map(lambda n: n * 2)Converting an error type
def f(raw: str) -> Result[int, AppError]: return parse(raw).map_err(lambda msg: AppError(message=msg))Or the match-explicit form when you want to inspect the error first:
def f(raw: str) -> Result[int, AppError]: match parse(raw): case Ok(n): return Ok(n) case Err(msg): return Err(AppError(message=msg))Chaining with .and_then
def pipeline(src: str) -> Result[TypedAst, AppError]: return ( tokenize(src) .map_err(_lex_to_app) .and_then(parse_toks) .and_then(check_ast) )The chain is functionally equivalent to a with-chain of ? operators; the choice is style.
Bridging exceptions: try_result and rescue
Third-party Python raises exceptions. Rather than scatter try/except shims, lift the boundary into a Result once and use ? / match downstream.
try_result(thunk[, on_err]) runs thunk(), returning Ok(result) or, on any exception, Err(on_err(exc)) — or Err(exc) (the raw exception) when no mapper is given. It is a prelude name (no import needed), typed as Result[T, E].
def load(path: str) -> Result[dict[str, str], str]: return try_result(lambda: read_json(path), lambda e: f"invalid JSON: {e}")Postfix rescue
For the common single-boundary case, the postfix rescue operator says the same thing with no lambdas, no try, and no except:
def load_port(raw: str) -> Result[int, str]: let n: int = int(raw) rescue e: f"bad port: {e}" return Ok(n)EXPR rescue NAME: ERR runs EXPR; on any exception it binds the exception to NAME, evaluates ERR, and propagates Err(ERR) to the enclosing function exactly like ? (so the enclosing function must return a compatible Result). It is surface sugar for try_result(lambda: EXPR, lambda NAME: ERR)?, and composes with as!:
let data: dict[str, str] = json.loads(text) as! dict[str, str] rescue e: f"bad json: {e}"Block rescue
The block form maps any exception raised across a whole suite — the lambda-free replacement for a try/except shim:
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"])))It lowers to try / except Exception as e: return Err(...). The mapped error type is checked against the function’s declared error type in both forms (tyc::result_error_mismatch).
Where next
- Error Handling (tour) — teaching page.
- The ? Operator — every rule.
with-chains — sequencing Results.- Errors as Values — design pattern.