Skip to content

with-chains

A with-chain sequences several Result-producing expressions, binding each success value, with an optional else err: block for the first Err. Modelled on Elixir.

Syntax

with NAME = EXPR "?" ("," NAME = EXPR "?")* ":" suite ["else" NAME ":" suite]
with user = db.find(id)?,
perms = check(user)?,
report = build(user, perms)?:
return Ok(report)
else err:
log.warn(err)
return Err(err)

Read as: bind each name by unwrapping the Ok of its expression; if any expression yields Err, jump to the else err: block with that error in scope.

Rules

  • Each binding’s expression must be a Result-typed expression with ?.
  • Names are introduced into the with-block’s suite (and the else block sees only err).
  • The enclosing function must return a Result if else is omitted (the chain short-circuits via ? to the enclosing return).
  • else err: is optional. Without it, the first Err propagates as if you had written let x = expr? for each binding.

With or without else

With else err:

def f() -> Result[int, str]:
with a = step1()?,
b = step2(a)?:
return Ok(a + b)
else err:
log.warn(err)
return Err(err)

The else block runs once, with err bound to the first Err’s payload.

Without else

def f() -> Result[int, str]:
with a = step1()?,
b = step2(a)?:
return Ok(a + b)

Same as:

def f() -> Result[int, str]:
let a: int = step1()?
let b: int = step2(a)?
return Ok(a + b)

The chain reads top-down as a sequence; on first Err, the function returns.

How with-chains desugar

with a = f()?, b = g(a)?:
return Ok(a + b)
else err:
return Err(err)

No nested try blocks, no exception machinery. Stack traces stay clean.

Bindings inside the chain

The binding names introduced by with-chains do not need let / mut — they are single-assignment by construction, scoped to the with-block’s suite.

Bindings in the else block

The err name in else err: is the unwrapped payload of the first Err, not the Err itself. Its type is the E of the chained Results (must match across all bindings).

Error type compatibility

All ?-applied expressions in the chain must agree on E. Mixing error types is a tyc::result_error_mismatch. Convert at the boundary:

def f() -> Result[T, AppError]:
with parsed = parse_str()?, # Result[int, str] — ❌
loaded = load_int(parsed)?: # Result[T, AppError]
return Ok(loaded)

Fix with .map_err:

def f() -> Result[T, AppError]:
with parsed = parse_str().map_err(lambda msg: AppError(message=msg))?,
loaded = load_int(parsed)?:
return Ok(loaded)

Or unfold the bridge with match:

def f() -> Result[T, AppError]:
match parse_str():
case Ok(parsed):
with loaded = load_int(parsed)?:
return Ok(loaded)
case Err(msg):
return Err(AppError(message=msg))

Since v0.8.0 the check also runs against the implicit propagation path on every ?-binding, so a chain that would have silently routed the wrong error class through the enclosing return now fires tyc::result_error_mismatch at check time. Since v0.9.0 the same check covers the explicit else err: return Err(err) form too — previously the check was gated on the synthetic ?-op temp shape, so an else block could return an Err whose payload type didn’t match the function’s declared return.

When to use a with-chain

  • 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, plain let x = expr? on separate lines reads fine.

Where next