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:
-
Inside a function whose return type is a compatible
Result[U, E']. TheEof the unwrapped expression must be assignable toE'. -
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 nerror[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)?_tmp_0 = parse(raw)if isinstance(_tmp_0, Err): return _tmp_0n: int = _tmp_0.valueNo 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_opSince 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 NoneFix: 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 expressionFix: don’t.
Using ? inside a comprehension
See the section above — pre-extract with an explicit loop instead.
Where next
Result, Ok, Err— the type and its constructors.with-chains — sequencing multiple?-propagations.- Error Handling (tour) — teaching page.