Fallible and infallible paths¶
This tutorial shows how to choose between plain return values, Option, Result, and panics.
Coming from Python¶
Python code often uses None, raise, try/except, and assert to describe paths that do not produce the ordinary value. Incan keeps those paths visible in signatures and at call sites.
| Python habit | Incan shape |
|---|---|
None means "not found" or "not present" |
Option[T] |
ValueError, OSError, or a custom recoverable exception |
Result[T, E] |
raise SomeError(...) |
return Err(SomeError(...)) |
try/except handles a failure locally |
match on Result |
| Let an exception bubble to the caller | ? propagates an Err(...) |
assert for an impossible state or programmer mistake |
panic() or a narrowly proven path |
In Python, an exception can skip the rest of the current block and search outward for a handler. In Incan, fallible paths are ordinary return values. Propagation is visible where it happens:
def load_username(path: str) -> str:
try:
text = read_text(path)
except OSError:
return "guest"
if text.strip() == "":
raise ValueError("name must not be empty")
return text.strip()
from std.fs import IoError, Path
enum NameError:
Io(IoError)
Empty
def read_username(path: Path) -> Result[str, NameError]:
text = path.read_text("utf-8", "strict").map_err(NameError.Io)?
name = text.strip()
if len(name) == 0:
return Err(NameError.Empty)
return Ok(name)
def load_username(path: Path) -> str:
match read_username(path):
Ok(name) => return name
Err(NameError.Io(_)) => return "guest"
Err(NameError.Empty) => return "guest"
The important difference is not syntax. It is control flow: Incan makes recovery (match) and propagation (?) explicit instead of hiding them behind exception unwinding.
The short rule:
- Return a plain value when the operation is expected to succeed for valid inputs.
- Return
Option[T]when "not found" or "not present" is an ordinary outcome. - Return
Result[T, E]when the caller may need to recover, retry, report, or attach context. - Use
panic()orunwrap()only for bugs and broken invariants, not normal user, file, network, or config failures.
Start with an infallible path¶
An infallible function returns the value directly. The caller does not need error handling.
model User:
id: int
name: str
def display_name(user: User) -> str:
return user.name.strip()
This function can still be wrong if the program built a bad User, but there is no expected runtime failure for the caller to handle. The signature says: pass a User, get a str.
Use Option for absence¶
Use Option when the operation may not find a value and absence is not itself an error.
def find_user(id: int, users: list[User]) -> Option[User]:
for user in users:
if user.id == id:
return Some(user)
return None
def print_user_if_present(id: int, users: list[User]) -> None:
match find_user(id, users):
Some(user) => println(display_name(user))
None => println("no user found")
The caller decides whether None is acceptable. This is better than inventing an error when the outcome is simply "not there".
Use Result for recoverable failure¶
Use Result when failure carries meaning that the caller may need to act on.
enum SignupError:
EmptyName
DuplicateUser(int)
def register_user(id: int, raw_name: str, users: list[User]) -> Result[User, SignupError]:
name = raw_name.strip()
if len(name) == 0:
return Err(SignupError.EmptyName)
match find_user(id, users):
Some(_) => return Err(SignupError.DuplicateUser(id))
None =>
user = User(id=id, name=name)
return Ok(user)
This path is fallible because the caller may respond differently to different errors:
def explain_signup(id: int, raw_name: str, users: list[User]) -> str:
match register_user(id, raw_name, users):
Ok(user) => return f"created {display_name(user)}"
Err(SignupError.EmptyName) => return "name is required"
Err(SignupError.DuplicateUser(existing_id)) => return f"user {existing_id} already exists"
Propagate when the caller owns the decision¶
Use ? when the current function cannot make the right recovery decision. The containing function must also return a compatible Result.
from std.fs import IoError, Path
enum ImportError:
Io(IoError)
Signup(SignupError)
def import_user(path: Path, users: list[User]) -> Result[User, ImportError]:
data = path.read_bytes().map_err(ImportError.Io)?
name = parse_user_name(data).map_err(ImportError.Signup)?
user = register_user(42, name, users).map_err(ImportError.Signup)?
return Ok(user)
The ? is visible in the body, and the fallibility is visible in the return type. There is no hidden exception path.
Compose Result with combinators¶
Use Result combinators when the code is transforming one branch of a result rather than making a multi-branch decision. The six standard methods are Rust-shaped and keep the same names in Incan: map, map_err, and_then, or_else, inspect, and inspect_err.
| Method | Callable argument | Use it when |
|---|---|---|
map |
Callable[T, U] |
transform an Ok(T) value into Ok(U) |
map_err |
Callable[E, F] |
transform an Err(E) value into Err(F) |
and_then |
Callable[T, Result[U, E]] |
chain another fallible operation after success |
or_else |
Callable[E, Result[T, F]] |
recover from or remap a failure with another fallible operation |
inspect |
Callable[T, None] |
observe or log a success value without changing the result |
inspect_err |
Callable[E, None] |
observe or log an error value without changing the result |
The method form is usually the clearest spelling because the value flows left to right. The same operations are also available from std.result when a function-shaped API reads better or when you need to pass a combinator as a named helper:
from std.result import map as result_map
def double(value: int) -> int:
return value * 2
def double_if_present(result: Result[int, str]) -> Result[int, str]:
return result_map(result, double)
map and map_err are good at API boundaries because they keep the success path and error path separated:
from std.fs import IoError, Path
enum LoadError:
Io(IoError)
Parse(str)
def parse_profile(data: bytes) -> Result[Profile, str]:
# Imagine a real parser here.
return Ok(Profile.from_bytes(data))
def parse_profile_for_load(data: bytes) -> Result[Profile, LoadError]:
return parse_profile(data).map_err(LoadError.Parse)
def load_profile(path: Path) -> Result[Profile, LoadError]:
return path.read_bytes().map_err(LoadError.Io).and_then(parse_profile_for_load)
and_then avoids nested match when the next step is also fallible. or_else keeps recovery explicit:
def read_default_profile(_err: LoadError) -> Result[Profile, LoadError]:
return Ok(Profile.guest())
def load_profile_or_guest(path: Path) -> Result[Profile, LoadError]:
return load_profile(path).or_else(read_default_profile)
Use inspect and inspect_err for side effects such as logging, metrics, or debugging taps. They return the original Result unchanged, so they should not replace code that needs to transform the value or recover. The compiler passes the observed payload to the callable through an implicit borrow when preserving the branch requires it, which lets the original Ok or Err value continue through the pipeline without forcing a clone just because it was observed.
def log_profile(profile: Profile) -> None:
println(f"loaded profile {profile.name}")
def log_load_error(err: LoadError) -> None:
println(f"profile load failed: {err}")
def load_profile_with_logging(path: Path) -> Result[Profile, LoadError]:
return load_profile(path).inspect(log_profile).inspect_err(log_load_error)
Prefer match when both branches matter, when you need multiple statements with local branching, or when the reader benefits from seeing every case explicitly. Prefer combinators when the intent is a single branch transformation, chain, recovery, or inspection tap.
Convert at boundaries¶
Low-level functions should usually return low-level errors. Boundary functions should convert those errors into the language of the caller.
That keeps public APIs stable and caller-oriented. A CLI should not leak parser, filesystem, or backend implementation types when the useful caller-facing question is whether config can be read or validated.
from std.fs import IoError, Path
enum CliError:
CouldNotReadConfig(str)
InvalidConfig(str)
def config_read_error(err: IoError) -> CliError:
return CliError.CouldNotReadConfig(err.message())
def config_signup_message(err: SignupError) -> str:
match err:
SignupError.EmptyName => return "name is required"
SignupError.DuplicateUser(existing_id) => return f"user {existing_id} already exists"
def config_signup_error(err: SignupError) -> CliError:
return CliError.InvalidConfig(config_signup_message(err))
def load_cli_user(path: Path, users: list[User]) -> Result[User, CliError]:
data = path.read_bytes().map_err(config_read_error)?
name = parse_config_user_name(data).map_err(config_signup_error)?
user = register_user(42, name, users).map_err(config_signup_error)?
return Ok(user)
Only the boundary needs to know whether config came from files, environment variables, network calls, or generated defaults.
Keep panics for invariants¶
unwrap() says "this cannot fail here." If it can fail because of user input, config, files, network state, or ordinary runtime data, use Result or Option instead.
def first_registered_user(users: list[User]) -> User:
# Valid only if the caller has already proven the list is non-empty.
return users[0]
If the list might be empty in normal use, make that fact explicit:
def maybe_first_registered_user(users: list[User]) -> Option[User]:
if len(users) == 0:
return None
return Some(users[0])
Decision checklist¶
Ask these in order:
- Can valid input still produce an expected non-value? Use
Option[T]. - Can valid input still fail in a way the caller may recover from or report? Use
Result[T, E]. - Is failure a bug in the caller or an impossible internal state? A panic may be acceptable.
- Is the current function the right place to decide? If yes,
match; if no,?. - Is this a public boundary? Convert low-level errors into the caller-facing error type.
- Is the code only transforming, chaining, recovering, or observing one
Resultbranch? Use a Result combinator.
Try it¶
- Write
def parse_age(raw: str) -> Result[int, str]that rejects an empty string. - Write
def find_age(name: str, ages: dict[str, int]) -> Option[int]. - Write
def describe_age(name: str, ages: dict[str, int]) -> strthat handlesNonelocally. - Write
def require_age(name: str, ages: dict[str, int]) -> Result[int, str]that convertsNoneintoErr(...). - Rewrite a nested
matchoverResult[int, str]usingmap,map_err, orand_then.
What to learn next¶
- Core concepts: Error handling
- Recipes: Error handling recipes
- File APIs with
Result: File I/O