Skip to content

The ? Operator

The ? suffix is sugar for “unwrap the Ok or short-circuit the Err to the enclosing function.” It is the only way to propagate Result errors compactly.

Syntax

EXPR?

Where EXPR has type Result[T, E] for some T and E. The result of EXPR? is T.

let n: int = parse_port(raw)?

Getting a Result to ? in the first place

? needs a Result-typed expression. When the value you want to propagate comes from a throwing boundary call (a library that raises), try_result is the idiomatic way to turn that call into a Result you can then ?-propagate, all in one expression:

def f() -> Result[dict[str, str], str]:
let cfg: dict[str, str] = try_result(lambda: read_json(path), lambda e: str(e))?
return Ok(cfg)

Going the other way — when you want to deliberately leave the Result world rather than propagate — the unwrap/query family (.unwrap(), .unwrap_or(d), .ok(), .is_ok(), …) is the escape hatch.

Where ? is allowed

? is only allowed in two places:

  1. Inside a function whose return type is a compatible Result[U, E']. The E of the unwrapped expression must be assignable to E'.

  2. Inside a with-chain binding (which itself is inside a Result-returning function).

def f() -> Result[int, str]:
let n: int = parse_port("8080")? # ✅ enclosing returns Result[int, str]
return Ok(n)
def g() -> int:
let n: int = parse_port("8080")? # ❌ enclosing returns int, not Result
return n
error[tyc::invalid_question_op]: `?` can only short-circuit inside a function
returning a compatible `Result`

Error type compatibility

The E of ?-applied expression must be assignable to the enclosing function’s Result[_, E']:

def parse() -> Result[int, str]: ...
def f() -> Result[int, str]:
let n: int = parse()? # ✅ E (str) matches enclosing E (str)
return Ok(n)
def g() -> Result[int, AppError]:
let n: int = parse()? # ❌ E mismatch — str not assignable to AppError
return Ok(n)
error[tyc::result_error_mismatch]: `?` returns Err(str), but the enclosing function
returns Result[int, AppError]

To bridge incompatible error types, convert explicitly with match:

def g() -> Result[int, AppError]:
match parse():
case Ok(n):
return Ok(n)
case Err(msg):
return Err(AppError(message=msg))

How ? desugars

? does not lower to try/except. It’s a plain inline check:

let n: int = parse(raw)?

No hidden exception flow. Every short-circuit is visible in the emitted source. Stack traces remain meaningful.

The temporary _tmp_N names are reserved by the desugarer and won’t collide with user names.

In expression position

? can appear inside any expression that participates in a Result-returning function:

def total(xs: list[str]) -> Result[int, str]:
mut sum: int = 0
for raw in xs:
sum = sum + parse(raw)? # ✅ inline use
return Ok(sum)

The check happens at each evaluation; on Err, the function returns immediately.

In with-chain bindings

with requires ? on each binding:

with user = db.find(id)?,
perms = check(user)?,
report = build(user, perms)?:
return Ok(report)
else err:
log.warn(err)
return Err(err)

The ? is what distinguishes a chained Result-unwrap from a regular with statement (context manager). See with-chains.

? is not allowed inside a comprehension

Comprehensions lower to nested for-loops in Python, so the surrounding “enclosing function” ? would short-circuit out of is not the comprehension’s frame — it’s an inner generator scope. ? inside a list comprehension, set comprehension, dict comprehension, or generator expression is rejected with tyc::invalid_question_op:

def collect(xs: list[str]) -> Result[list[int], str]:
return Ok([parse(x)? for x in xs]) # ❌ tyc::invalid_question_op

Since v0.9.0 the diagnostic help text explicitly covers both this case and the Result-return cause. The fix is to pre-extract with an explicit loop or to chain .and_then / .map:

def collect(xs: list[str]) -> Result[list[int], str]:
mut out: list[int] = []
for x in xs:
out.append(parse(x)?)
return Ok(out)

Or, if every element is fault-tolerant, lift the loop with .map_err:

def collect(xs: list[str]) -> Result[list[int], str]:
mut out: list[int] = []
for x in xs:
match parse(x):
case Ok(n): out.append(n)
case Err(msg): return Err(msg)
return Ok(out)

? propagation inside with-chains is fully checked (v0.8.0)

When a with-chain binding uses ?, the implicit “fall-through” path returns Err(...) to the enclosing function. v0.8.0 added error-type validation on that implicit path so a mismatching error class no longer slips through:

def step1() -> Result[A, str]: ...
def step2(a: A) -> Result[B, AppError]: ...
def both() -> Result[B, str]:
with a = step1()?,
b = step2(a)?: # ❌ result_error_mismatch — AppError ⇏ str
return Ok(b)

v0.9.0 extends this check to the explicit else err: return Err(err) shape too — previously the validation was gated on the synthetic ?-op temp shape, so an else block could silently return the wrong error class.

Common mistakes

Using ? outside a Result function

def main() -> None:
let n: int = parse("42")? # ❌ main returns None

Fix: change the signature, or match explicitly.

Mismatched error types

def f() -> Result[int, AppError]:
let n: int = parse_str()? # ❌ if parse_str returns Result[int, str]

Fix: convert at the boundary (see above), or use .map_err:

def f() -> Result[int, AppError]:
let n: int = parse_str().map_err(lambda msg: AppError(message=msg))?
return Ok(n)

Using ? on a non-Result value

let n: int = (1 + 2)? # ❌ ? requires a Result-typed expression

Fix: don’t.

Using ? inside a comprehension

See the section above — pre-extract with an explicit loop instead.

Where next