Errors as Values
Result[T, E] puts errors in the type system. The remaining design question is: what’s E? Four common shapes, with trade-offs.
1. A string (good for prototypes)
def parse_port(raw: str) -> Result[int, str]: ...- ✅ Easy to read, easy to write.
- ❌ Can’t pattern-match on the kind of failure — only the text.
- ❌ Localisation / i18n is harder later.
Use for early development and one-off scripts.
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]: ...
# At the call site:match parse_port(raw): case Ok(p): listen(p) case Err(NotANumber(r)): report(f"bad port number: {r}") case Err(OutOfRange(v, min, max)): report(f"port {v} not in [{min}, {max}]")- ✅ Exhaustive match enforces handling every variant.
- ✅ Carries structured data per variant.
- ✅ Adding a variant lights up every match site (the seal-and-extend pattern).
The recommended default for domain code.
3. A class hierarchy (good for cross-cutting errors)
class AppError: message: str correlation_id: str
class DatabaseError(AppError): query: str
class HttpError(AppError): status: int- ✅ Uniform shape for logging / serialisation.
- ✅ Subtype polymorphism —
Result[T, AppError]accepts any subclass. - ❌ Sealed unions are stronger for exhaustiveness.
Use when many call sites need the same error-shaped logging / metrics, regardless of the kind.
4. A wrapped exception (boundary pattern)
class ExternalFailure: cause: Exception
def call_external() -> Result[Response, ExternalFailure]: try: return Ok(http.get("...")) except (requests.RequestException, TimeoutError) as e: return Err(ExternalFailure(cause=e))- ✅ Bridges the Python exception world cleanly.
- ❌ The error type erases the kind of failure (you lose
match-ability one).
Use at the boundary into untyped libraries; convert to a sealed union inside your domain.
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 into one expression. It is a prelude name (no import needed), runs the thunk and returns Ok(result), or returns Err(on_err(exc)) on any exception:
def call_external() -> Result[Response, ExternalFailure]: return try_result(lambda: http.get("..."), lambda e: ExternalFailure(cause=e))Omit the mapper to carry the raw exception as Result[Response, Exception]. Reach for the explicit multi-except try shim when you map distinct exception types to distinct errors; reach for try_result for the common single-boundary case. See try_result.
Layering
A common pattern: each layer defines its own sealed-union error type, and converts at the boundary.
type StorageError = NotFound | Conflict | Backend
# auth/errors.tytype AuthError = StorageError | Forbidden | InvalidToken
# api/errors.tytype ApiError = AuthError | ValidationError | RateLimitedThe conversion happens at each layer boundary:
def serve(token: str) -> Result[User, ApiError]: match validate(token): case Err(InvalidToken(reason)): return Err(InvalidToken(reason=reason)) # bubble case Err(e): return Err(ValidationError(detail=str(e))) # convert case Ok(claims): return resolve_user(claims)?Use the ? operator when error types match; match to convert when they don’t.
Anti-patterns
Returning Result[T, Exception] everywhere
def f() -> Result[T, Exception]: ...Loses all the safety. Exception is too broad to pattern-match meaningfully. Convert to a sealed union at the boundary.
Mixing exception-raising and Result-returning in one function
def f() -> Result[int, str]: if cond: raise ValueError("bad") # ❌ caller has both to handle return Ok(42)Pick one — usually Result. The raise undermines the type signature.
Using Result[T, str] for production code
The string-as-error pattern is fine for prototypes; it doesn’t scale. Define a proper error type as soon as you have multiple call sites.
Escaping Result deliberately
Most code propagates a Result with ? or with-chains. When you genuinely want to leave the Result world — a default-or-fail at the edge of a program, a script, or a test — the unwrap/query family (added v0.13.0) is the intended escape hatch:
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(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(). 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.
Where next
- Error Handling (tour) — teaching page.
Result, Ok, Errreference — the type.- Sealed Unions — the type for
E.