Skip to content

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 value
Err(error) # E inferred from error
Ok[int](42) # explicit T
Err[str]("oops") # explicit E

Ok 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 dataclass
from 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:

MethodReceiverSemantics
.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:

MethodSignatureOn Ok(v)On Err(e)
.unwrap()() -> Tvraises
.expect(msg)(str) -> Tvraises with msg
.unwrap_or(default)(T) -> Tvdefault
.unwrap_or_else(f)((E) -> T) -> Tvf(e)
.ok()() -> T?vNone
.err()() -> E?Nonee
.is_ok()() -> boolTrueFalse
.is_err()() -> boolFalseTrue
let port: int = parse("8080").unwrap_or(80) # 80 on Err, never raises
let n: int = parse(raw).expect("config must hold a numeric port")
let maybe: int? = parse(raw).ok() # None on Err
if 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}")

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 build auto-injects from typhon_runtime import try_result (visible in the Emitted Python tab above), and the VM registers it as a prelude native for tyc run.
  • Typed Result[T, E], not Any. T is inferred from the thunk body, E from the mapper body (Exception when the mapper is omitted). It is special-cased in the checker (like as!), so a wrong return annotation still fires tyc::type_mismatch — try_result is a genuine Result, never an escape hatch.
  • Faithful under tyc run. The VM materialises the caught exception exactly as an except E as e: handler would, so lambda 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