Error Handling with Result
Exceptions exist in Python, and Typhon doesn’t remove them. But for expected failures — parse errors, missing records, validation rejections — Typhon uses a typed Result[T, E]. The compiler tracks errors as values, the ? operator propagates them, and with-chains sequence them like Elixir.
The problem with exceptions
Python’s exceptions don’t appear in type signatures. A function annotated def find_user(id: int) -> User might raise ValueError, KeyError, DatabaseError, or anything else — and callers can’t tell from the signature, the editor, or the type checker. The result is defensive try/except, missed cases, and stack traces in production.
Result[T, E] is the alternative: a return type that carries the failure as a value the caller must handle.
Result, Ok, Err
Result[T, E] is a sealed sum type with two constructors:
Ok(value: T)— successErr(error: E)— failure
def parse_port(raw: str) -> Result[int, str]: if not raw.isdigit(): return Err(f"not a number: {raw}") let n: int = int(raw) if n < 1 or n > 65535: return Err(f"port out of range: {n}") return Ok(n)The return type — Result[int, str] — tells you everything the function can do: succeed with an int, or fail with a str describing why. The compiler enforces that both branches return a Result.
Calling a Result-returning function
You have to handle both cases. Pattern-matching is the most explicit form:
def main() -> None: match parse_port("8080"): case Ok(port): print(f"binding {port}") case Err(msg): print(f"bad port: {msg}")The checker enforces that match on a Result covers both Ok and Err. (Sealed unions and exhaustive matching are covered in Sealed Unions and Match.)
The ? operator
Manual match everywhere is noisy. The ? suffix is sugar for “unwrap the Ok or short-circuit the Err to the enclosing function”:
def parse_address(host: str, port_str: str) -> Result[tuple[str, int], str]: let port: int = parse_port(port_str)? return Ok((host, port))If parse_port returns Err(msg), the ? short-circuits and parse_address returns Err(msg) immediately. If it returns Ok(n), port is bound to n and execution continues.
The compiler checks that ? is only used inside a function whose return type is a compatible Result. Otherwise where would the early return go?
def bad() -> int: let n: int = parse_port("8080")? # ❌ tyc::invalid_question_op return nerror[tyc::invalid_question_op]: `?` can only short-circuit inside a function returning a compatible `Result` ┌─ src/main.ty:2:13 │2 │ let n: int = parse_port("8080")? │ ^^^^^^^^^^^^^^^^^^^^ enclosing function returns `int`, not `Result`The “compatible” check is structural: the error types must match (or unify, for generics). You can’t ? an Err[ParseError] out of a function returning Result[T, IoError] — convert it first.
How ? desugars
? does not lower to try/except. It’s a plain inline check, keeping stack traces clean and predictable:
let port: int = parse_port(port_str)?_tmp_0 = parse_port(port_str)if isinstance(_tmp_0, Err): return _tmp_0port: int = _tmp_0.valueNo hidden exception flow. Every short-circuit is visible in the emitted source.
with-chains
Sequencing several Result-returning calls is common, and ? plus assignment gets repetitive. Typhon borrows Elixir’s with for this:
def make_report(user_id: int) -> Result[Report, AppError]: with user = db.find_user(user_id)?, perms = check_perms(user)?, report = build_report(user, perms)?: return Ok(report) else err: log.warn(err) return Err(err)Read it as: bind each name in turn, unwrapping Ok and binding the success value. If any step yields Err, jump to the else err: block with that error in scope.
The else err: block is optional. Without it, the first Err short-circuits straight out of the enclosing function (which must, of course, return a compatible Result).
When to reach for with-chains
- Three or more
Result-returning calls where each depends on the previous. - You want a single failure-handling site (logging, metrics, rollback) for any step that fails.
For one or two calls, ? on each line reads fine.
Combinators — .map, .map_err, .and_then, .or_else
For functional-style chaining, Ok and Err carry four combinator methods on the runtime classes:
def parse_and_double(raw: str) -> Result[int, str]: return parse_port(raw).map(lambda n: n * 2)
def parse_to_app(raw: str) -> Result[int, AppError]: return parse_port(raw).map_err(lambda msg: AppError(message=msg))
def step(raw: str) -> Result[Loaded, AppError]: return ( parse_port(raw) .map_err(lambda msg: AppError(message=msg)) .and_then(load_from_port) )| Method | On Ok(v) | On Err(e) |
|---|---|---|
.map(f) | Ok(f(v)) | Err(e) |
.map_err(g) | Ok(v) | Err(g(e)) |
.and_then(f) | f(v) (must return Result) | Err(e) |
.or_else(h) | Ok(v) | h(e) (must return Result) |
The combinators landed in v0.6.0 and have always worked under tyc build && python build/main.py. Since v0.9.0 they also work under tyc run — the in-process VM now binds the combinator methods natively, so a typecheck-clean program no longer crashes with AttributeError: Ok has no attribute 'and_then' under the default execution mode.
The choice between with-chain + ? and method chaining is style. with-chains tend to read better when each step uses the previous step’s success value by name; combinator chains tend to read better when each step is a function-of-one-argument.
Escaping Result — the unwrap / query family
The combinators above all stay inside the Result world. When you deliberately want to leave it — pull a default out at the edge of a program, fail loudly at startup, or ask a yes/no question — Ok / Err carry an unwrap/query family too (added v0.13.0):
let port: int = get_port(cfg).unwrap_or(8080) # default on Err, never raiseslet cfg: dict = load(path).expect("config is required at startup") # raise on Errlet maybe: int? = parse_port(raw).ok() # Result -> T?if load(path).is_ok(): ...The eight methods are .unwrap(), .expect(msg), .unwrap_or(d), .unwrap_or_else(f), .ok(), .err(), .is_ok(), and .is_err(). As of v0.13.0 the Result method surface is closed, so a typo’d method name is caught at check time (tyc::attribute_not_found) rather than crashing at runtime. See the unwrap/query family reference for the full table.
Choosing your error type
E in Result[T, E] can be anything. Three patterns by complexity:
1. A plain string (good for prototypes)
def parse_port(raw: str) -> Result[int, str]: ...Easy to read, easy to write, but you can’t pattern-match on the kind of failure — only the text.
2. A sealed union (good for domain code)
type ParseError = NotANumber | OutOfRange
class NotANumber: raw: str
class OutOfRange: value: int min: int max: int
def parse_port(raw: str) -> Result[int, ParseError]: ...Now callers can match on the specific failure and react. See Sealed Unions and Match for the full story.
3. A class hierarchy (good for cross-cutting errors)
class AppError: message: str correlation_id: strUse this when you have many call sites that just need a uniform shape to log/return.
Bridging exceptions and Result
Third-party Python libraries raise exceptions. Wrap them at the boundary:
import json
def load_config(path: str) -> Result[dict[str, str], str]: try: with open(path) as f: return Ok(json.load(f)) except FileNotFoundError: return Err(f"config not found: {path}") except json.JSONDecodeError as e: return Err(f"invalid JSON: {e}")After this wrapper, downstream code can use ? and with-chains without touching try.
When the boundary is a single call and you don’t need to distinguish exception types, try_result (v0.15.0) collapses that whole try/except shim into one expression. Like Ok / Err / Result, it is a prelude name (no import needed in source):
def load_config(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_config(path: str) -> Result[dict[str, str], str]: return try_result(lambda: read_json(path), lambda e: f"invalid JSON: {e}")It runs the thunk and returns Ok(result); on any exception it returns Err(on_err(exc)), or Err(exc) — the raw exception, giving Result[T, Exception] — when the mapper is omitted. The result is a genuine, typed Result (T inferred from the thunk, E from the mapper), so a wrong return annotation still fires tyc::type_mismatch. Reach for the explicit multi-except shim when you map distinct exception types to distinct errors; reach for try_result for the common single-boundary case. See try_result.
rescue — the same bridge without the lambdas
rescue (v1.0.0-alpha) says the same thing with no lambdas, no try, and no except. The postfix form guards one expression and propagates the mapped error exactly like ?; the block form maps any exception raised across a whole suite:
def parse_port(raw: str) -> Result[int, str]: let n: int = int(raw) rescue e: f"bad port: {e}" # postfix return Ok(n)
def load_config(text: str) -> Result[Config, str]: rescue e: f"bad config: {e}": # block let data: dict[str, str] = json.loads(text) as! dict[str, str] return Ok(Config(host=data["host"], port=parse_port(data["port"])?))from typhon_runtime import try_resultfrom typhon_runtime import Err as __typhon_Err__from typhon_runtime.cast import checked_cast as __typhon_checked_cast__
def parse_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)
def load_config(text: str) -> Result[Config, str]: try: data: dict[str, str] = __typhon_checked_cast__(json.loads(text), dict[str, str]) __typhon_qi_0__ = parse_port(data["port"]) if isinstance(__typhon_qi_0__, __typhon_Err__): return __typhon_qi_0__ return Ok(Config(host=data["host"], port=__typhon_qi_0__.value)) except Exception as e: return Err(f"bad config: {e}")Postfix rescue is sugar for try_result(lambda: EXPR, lambda e: ERR)?, so the enclosing function must return a compatible Result; the block form lowers to try / except Exception as e: return Err(ERR). In both, the mapped error is checked against the function’s declared error type (tyc::result_error_mismatch), and both run identically under tyc run and tyc build. See the reference and how it lowers.
What gets emitted
Result, Ok, and Err are emitted as tagged dataclasses in a generated typhon_runtime.py module that sits in your output tree:
# 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] # roughlyThe point: there is no PyPI dependency. Production servers run the emitted Python plus this small runtime helper, which is generated alongside your code.
Putting it together
A small worked example: read a config file, parse a port number, and report errors clearly.
import json
type ConfigError = NotFound | InvalidJson | MissingKey | BadPort
class NotFound: path: str
class InvalidJson: detail: str
class MissingKey: key: str
class BadPort: raw: str
def load(path: str) -> Result[dict[str, str], ConfigError]: try: with open(path) as f: return Ok(json.load(f)) except FileNotFoundError: return Err(NotFound(path=path)) except json.JSONDecodeError as e: return Err(InvalidJson(detail=str(e)))
def get_port(cfg: dict[str, str]) -> Result[int, ConfigError]: let raw: str? = cfg.get("port") guard r = raw else: return Err(MissingKey(key="port")) if not r.isdigit(): return Err(BadPort(raw=r)) return Ok(int(r))
def boot(path: str) -> Result[int, ConfigError]: with cfg = load(path)?, port = get_port(cfg)?: return Ok(port)
def main() -> None: match boot("config.json"): case Ok(port): print(f"listening on {port}") case Err(NotFound(path)): print(f"missing: {path}") case Err(InvalidJson(detail)): print(f"corrupt: {detail}") case Err(MissingKey(key)): print(f"config missing `{key}`") case Err(BadPort(raw)): print(f"`{raw}` is not a valid port")from __future__ import annotationsfrom typhon_runtime import Ok, Err, Resultimport dataclassesfrom typhon_runtime import Err as __typhon_Err__import json
type ConfigError = NotFound | InvalidJson | MissingKey | BadPort
@dataclasses.dataclass(slots=True)class NotFound: path: str
@dataclasses.dataclass(slots=True)class InvalidJson: detail: str
@dataclasses.dataclass(slots=True)class MissingKey: key: str
@dataclasses.dataclass(slots=True)class BadPort: raw: str
def load(path: str) -> Result[dict[str, str], ConfigError]: try: with open(path) as f: return Ok(json.load(f)) except FileNotFoundError: return Err(NotFound(path=path)) except json.JSONDecodeError as e: return Err(InvalidJson(detail=str(e)))
def get_port(cfg: dict[str, str]) -> Result[int, ConfigError]: raw: str | None = cfg.get("port") __typhon_mguard_0 = raw if __typhon_mguard_0 is None: return Err(MissingKey(key="port")) r = __typhon_mguard_0 if not r.isdigit(): return Err(BadPort(raw=r)) return Ok(int(r))
def boot(path: str) -> Result[int, ConfigError]: __typhon_with_0__ = load(path) if isinstance(__typhon_with_0__, __typhon_Err__): return __typhon_with_0__ cfg = __typhon_with_0__.value __typhon_with_1__ = get_port(cfg) if isinstance(__typhon_with_1__, __typhon_Err__): return __typhon_with_1__ port = __typhon_with_1__.value return Ok(port)
def main() -> None: match boot("config.json"): case Ok(port): print(f"listening on {port}") case Err(NotFound(path)): print(f"missing: {path}") case Err(InvalidJson(detail)): print(f"corrupt: {detail}") case Err(MissingKey(key)): print(f"config missing `{key}`") case Err(BadPort(raw)): print(f"`{raw}` is not a valid port")The whole error-handling story shows up here:
- Errors are values, with a sealed-union type (
ConfigError). ?short-circuits insideboot, which composes twoResult-returning calls.- The
matchinmainis exhaustive over every error variant — and the checker enforces that. Add a new variant toConfigErrorand the match goes red until you handle it.
Common mistakes
Using ? in a non-Result function
def main() -> None: let port: int = parse_port("8080")? # ❌ main returns NoneFix: change the signature, or match explicitly.
Mismatched error types
def parse_port(raw: str) -> Result[int, str]: ...
def boot(raw: str) -> Result[int, ConfigError]: let port: int = parse_port(raw)? # ❌ str not assignable to ConfigError return Ok(port)Fix: convert the error at the boundary, either with a helper or by using match:
def boot(raw: str) -> Result[int, ConfigError]: match parse_port(raw): case Ok(port): return Ok(port) case Err(_): return Err(BadPort(raw=raw))Forgetting one variant in match
The checker counts variants and refuses to compile with tyc::non_exhaustive_match listing the ones you missed. Add them or use case _: for a deliberate catch-all.
What you’ve learned
Result[T, E]makes failure visible in signatures;OkandErrare its two constructors.?propagates errors with one character; the checker enforces compatible return types.with-chains sequenceResult-producing calls with a single failure-handling site.- Errors emit as small dataclasses in a generated
typhon_runtime.py— no PyPI runtime. - Wrap exception-raising library calls in a
tryshim — ortry_result/ the lambda-freerescuesugar for a single boundary — and lift them intoResult. - Leave the
Resultworld deliberately with the unwrap/query family (.unwrap_or,.expect,.ok,.is_ok, …); the method surface is closed, so typos are caught at check time.
Where next
- Sealed Unions and Match — closed sum types and exhaustive matching.
Result, Ok, Err— the reference page.- The ? Operator — every rule the checker enforces.
with-chains — every variant of the form.