Question-Mark Operator (?)¶
The postfix ? operator is the early-return (error-propagation) operator. It
unwraps a Result[T, E] or
Optional[T] value, and — if the value represents
failure (Err) or absence (None) — it short-circuits the enclosing function by
returning that failure/absence to the caller. This is analogous to Rust's ?
operator.
def parse_pair(a: str, b: str) -> tuple[int, int] !ValueError:
x: int = int_parse(a)? # unwrap, or return Err(...) from parse_pair
y: int = int_parse(b)? # unwrap, or return Err(...) from parse_pair
return Ok((x, y))
Overview¶
expr? does one of two things at runtime:
| Operand | On success | On failure (early return) |
|---|---|---|
Result[T, E] |
evaluates to T |
return Err(e) from the function |
Optional[T] |
evaluates to T |
return None from the function |
It replaces verbose match / unwrap boilerplate for the common "propagate the
error upward" pattern, while keeping control flow explicit and visible at the
call site (the trailing ?).
Syntax¶
? is a postfix operator written immediately after the expression it
operates on:
The operand must statically have type Result[T, E] or Optional[T]. Using ?
on any other type is a compile-time error (see Restrictions).
Precedence¶
? is parsed in the postfix position, at the same level as member access
(.), indexing ([]), and calls (()). It binds tighter than every binary
operator. As a postfix operator it associates with the immediately-preceding
primary expression:
compute()? # (compute())? — the call result is unwrapped
a.b.c? # ((a.b).c)? — the whole member chain is unwrapped
x? + 1 # (x?) + 1 — x is unwrapped, then 1 is added
-x? # -(x?) — x is unwrapped, then negated
Because it is consumed in the postfix loop alongside ., [], and (), you can
freely mix it into a chain:
Supported Types¶
Result[T, E]¶
result? yields T when the value is Ok(T). When the value is Err(e), the
enclosing function returns Err(e) immediately.
def total(parts: list[str]) -> int !ValueError:
sum: int = 0
for p in parts:
sum += int_parse(p)? # any Err short-circuits the whole function
return Ok(sum)
Optional[T]¶
optional? yields T when the value is Some(T). When the value is None, the
enclosing function returns None immediately.
def first_upper(items: list[str]?) -> str?:
items_val = items? # if items is None, return None
return Some(items_val[0].upper())
Return-Type Requirements¶
? only makes sense when the enclosing function can itself propagate the
failure. The compiler enforces this:
?onResultrequires the enclosing function to return aResult[_, E2]. The operand's error typeE1must be assignable to the function's error typeE2(E1is convertible toE2). Otherwise the compiler reportsSPY0461.?onOptionalrequires the enclosing function to return anOptional[_]. Otherwise the compiler reportsSPY0461.
# ✅ E1 (ValueError) assignable to E2 (Exception)
def f(s: str) -> int !Exception:
return Ok(int_parse(s)?) # int_parse returns int !ValueError
# ❌ SPY0461 — function returns Optional, not Result
def g(s: str) -> int?:
return Some(int_parse(s)?) # int_parse returns int !ValueError
The early-return value is constructed against the function's declared types:
? on a Result[T, E1] inside a function returning Result[R, E2] emits a
return Result<R, E2>.Err(e).
Interaction with ?? (Null-Coalescing)¶
At the lexer level, ?? is now two ?
tokens, so a run of consecutive ? characters has to be disambiguated between
early-return (?) and null-coalescing (??). The parser uses the following
N-count rule:
Count the run of
Nconsecutive?tokens. If they are immediately followed by the start of an expression (so a right-hand operand exists) andN >= 2, then the last two?tokens form a??(null-coalesce) and the remainingN - 2are early-return?. Otherwise, allNare early-return?.
This yields the following intuitive cases:
| Source | N |
RHS expr? | Early-return ? |
Coalesce ?? |
Meaning |
|---|---|---|---|---|---|
x? |
1 | no | 1 | 0 | single early-return |
x?? |
2 | no | 2 | 0 | double early-return — unwrap a nested Result/Optional twice |
x ?? y |
2 | yes | 0 | 1 | null-coalesce (x or default y) |
x???y |
3 | yes | 1 | 1 | early-return once, then ?? y coalesce |
x????y |
4 | yes | 2 | 1 | early-return twice, then ?? y coalesce |
# x?? — x is a doubly-wrapped value (e.g. Optional[Optional[int]]); unwrap twice,
# early-returning at whichever level is empty.
inner: int = x??
# x???y — early-return once, then coalesce the result with y
value: int = compute()??? fallback
# └┬┘└──┬──┘
# early-return ? ?? y coalesce
The disambiguation is purely syntactic (token counting). Each early-return
?and the final??are then type-checked independently against the operand and RHS types.
Restrictions¶
| Condition | Diagnostic | Message (abbreviated) |
|---|---|---|
| Used at module level / outside any function | SPY0462 |
'?' operator can only be used inside a function |
Used inside a finally: block |
SPY0211 |
'?' operator cannot be used inside a 'finally' block |
Operand is not Result or Optional |
SPY0460 |
'?' operator requires Result or Optional type, got '...' |
| Return type incompatible / error mismatch | SPY0461 |
'?' ... is not assignable to function return error type ... |
? is disallowed in finally blocks because an early return from finally
would silently discard a pending exception or return value, which is a footgun
rather than error propagation.
Interaction with try / maybe¶
?, try, and maybe are
complementary:
?propagates an existingResult/Optionalfailure upward by early-returning it. It does not change representation; it consumes one.try exprconverts a throwing expression (exceptions /T | None) into aResult, so its failures can then be propagated with?.maybe exprconverts aT | None(.NET nullable) into anOptional[T], so its absence can then be propagated with?.
Because ? binds tighter than the prefix try/maybe keywords, parenthesize
the conversion so that ? applies to the produced Result/Optional rather than
to the inner (un-converted) value:
def load(path: str) -> Config !IOError:
# (try ...) wraps a throwing call into a Result; ? then propagates any Err upward.
# Parentheses are required: `try read_file(path)?` would apply ? to the raw
# return value (not a Result) and fail to type-check.
content: str = (try read_file(path))?
return Ok(parse(content))
Desugaring¶
expr? is lowered by hoisting statements before the use site and replacing the
expression with an Unwrap() call. For an operand of type Result[T, E1] inside
a function returning Result[R, E2]:
desugars to (conceptually):
var __qm_0 = compute();
if (__qm_0.IsErr)
return Result<R, E2>.Err(__qm_0.UnwrapErr());
var value = __qm_0.Unwrap();
For an operand of type Optional[T] inside a function returning Optional[R]:
desugars to:
Nested ? (e.g. x??) desugars depth-first: the inner ? hoists its guard
first, then the outer ? operates on the already-unwrapped temporary.
Examples¶
Chained parsing with early return¶
def parse_point(s: str) -> Point !ValueError:
parts: list[str] = s.split(",")
if len(parts) != 2:
return Err(ValueError("expected 'x,y'"))
x: int = int_parse(parts[0])? # propagate ValueError on bad x
y: int = int_parse(parts[1])? # propagate ValueError on bad y
return Ok(Point(x, y))
Optional propagation¶
def head_len(items: list[str]?) -> int?:
xs = items? # None in -> None out
if len(xs) == 0:
return None
return Some(len(xs[0]))
Mixing with try¶
def read_int(path: str) -> int !Exception:
text: str = (try read_file(path))? # exception -> Err -> propagated
return Ok((try int_parse(text))?) # parse failure -> Err -> propagated
Implementation
- ✅ Implemented — the postfix ? operator is supported end-to-end (lexer,
parser, semantic analysis, code generation, and LSP). It lowers to an
IsErr/IsNone guard plus Unwrap()/UnwrapErr(), with zero heap
allocation (operates on the Result/Optional structs).
See Also¶
- Result Type —
Result[T, E]/T !E - Optional Type —
Optional[T]/T? - Null-Coalescing Operator —
??(now two?tokens) - Try Expressions — convert throwing code into
Result - Maybe Expressions — convert
T | NoneintoOptional[T] - Operator Precedence — full precedence table