Skip to content
TE827 · Type · error

`?.` null-safe chain on `Result`/`Option`

`?.` is null-safe chaining, not fallible unwrapping. A `Result` or `Option` is never `nil`, so the chain runs over the wrapper itself. Use `expr ?> .m(...)`, `let v = expr?`, or `expr!.m(...)` instead.

Why this fires

The ?. operator in Zolo is null-safe chaining: it skips the method call and returns nil when the receiver is nil. A Result or Option is a wrapper value, not a nilable reference — it is never nil, so the chain always runs over the wrapper itself instead of its payload.

use std::Database

let db = Database.open("sqlite://:memory:")?

db.query("SELECT * FROM users")?.each(|u| print(u.name))
//                              ^^ error[TE827]: `?.` is null-safe chaining, but the receiver
//                                 is a `Result` — a `Result` is never nil.
//                                 Chain on the Ok value instead:
//                                 `expr ?> .each(...)` (propagate),
//                                 `let v = expr?` then `v.each(...)` (unwrap),
//                                 or `expr!.each(...)` (panic on Err)

This looks like Rust's try-then-chain syntax, but the operators have different semantics in Zolo. ?. checks for nil; it does not unwrap a Result. Because a Result is never nil, the null-safe guard is a no-op and the chain attempts to call .each directly on the wrapper. .each is not a method of Result — historically this died with a cryptic R0001: attempt to call method 'each' (a nil value); the runtime now converts that into the friendly wrapper-guard message (via __zolo_optchain_call), and TE827 catches it statically before it ever runs.

The same applies to Option:

fn find_user(id: int) -> Option<User> { ... }

find_user(42)?.name
//            ^^ error[TE827]: `?.` is null-safe chaining, but the receiver is an `Option`
//               — an `Option` is never nil. Chain on the Some value instead:
//               `expr ?> .name(...)` (propagate), `let v = expr?` then `v.name(...)` (unwrap),
//               or `.unwrap_or(...)`

?. is correct when the receiver is a nilable wrapper — i.e. the type is Result<T, E>? or Option<T>?:

fn maybe_result(flag: bool) -> Result<int, str>? {
    if flag { Result.Ok(1) } else { nil }
}

maybe_result(false)?.is_ok()   // fine — receiver may be nil

Fix it

1. Fallible pipe ?> (propagate Err/None)

Replace ?. with ?> followed by a dot chain. The function must return a compatible Result or Option:

db.query("SELECT * FROM users") ?> .each(|u| print(u.name))

2. Bind then use

Unwrap with the ? postfix operator (propagates Err/None) and call the method on the bound value:

let rows = db.query("SELECT * FROM users")?
rows.each(|u| print(u.name))

3. Force chain !. (panic on Err/None)

If a failure is a programming error and a panic is acceptable:

db.query("SELECT * FROM users")!.each(|u| print(u.name))

For Option, you can also use .unwrap_or(default) to avoid a potential panic:

find_user(42).unwrap_or(User.default()).name

Notes

  • ?. (null-safe), ?> (fallible pipe), ? (unwrap/propagate), and !. (force chain) are four orthogonal operators. See docs/06-operators.md for their full semantics.
  • The check is by type name, so a user-declared enum literally named Result or Option also trips TE827. The prelude names are effectively reserved.
  • When the receiver type is not statically known (Any), TE827 is not emitted. A runtime guard in the __zolo_optchain_call prelude helper still catches the mistake and emits a friendly message instead of the cryptic raw runtime error.
  • TE827 fires for all members accessed via ?. on a wrapper, not only collection methods. Even result?.unwrap() is flagged: a wrapper is never nil, so ?. over one is always a mistake.

See also

See also

enespt-br