Skip to content

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 on e).

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.

storage/errors.ty
type StorageError = NotFound | Conflict | Backend
# auth/errors.ty
type AuthError = StorageError | Forbidden | InvalidToken
# api/errors.ty
type ApiError = AuthError | ValidationError | RateLimited

The 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 raises
let cfg: dict = load(path).expect("config is required at startup") # raise on Err
let 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