Error Handling¶
Desi uses Option and Result types for safe error handling, inspired by Rust. This approach catches errors at compile time rather than runtime.
Philosophy¶
No Exceptions
Desi doesn't have exceptions. Instead, errors are values that must be explicitly handled. This makes error handling visible and enforced by the type system.
Option Type¶
Option<T> represents a value that may or may not exist:
# Option<T> has two variants:
# - Some(value) - contains a value of type T
# - Nothing - no value present
Creating Options¶
# Some variant - contains a value
let x: Option<int> = Option.Some(42)
# Nothing variant - no value
let y: Option<str> = Option.Nothing
Pattern Matching¶
let value: Option<int> = Option.Some(42)
match value:
Option.Some(v): print(f"Got value: {v}")
Option.Nothing: print("No value")
Option Methods¶
Desi's Option type provides a rich set of helper methods to query, transform, and unwrap optional values safely.
| Method | Signature / Return | Description |
|---|---|---|
is_some() |
() -> bool |
Returns true if the option is Some. |
is_none() / is_nothing() |
() -> bool |
Returns true if the option is Nothing. |
unwrap() |
() -> T |
Returns the inner value of Some, or panics if Nothing. |
unwrap_or(default) |
(default: T) -> T |
Returns the inner value if Some, otherwise returns the default value. |
unwrap_or_else(fn) |
(fn: () -> T) -> T |
Returns the inner value if Some, otherwise computes a default by calling fn (lazy evaluation). |
expect(msg) |
(msg: str) -> T |
Returns the inner value of Some, or panics with the custom msg if Nothing. |
map(fn) |
(fn: (T) -> U) -> Option<U> |
Maps an Option<T> to Option<U> by applying fn to the contained value. |
and_then(fn) |
(fn: (T) -> Option<U>) -> Option<U> |
Returns Nothing if the option is Nothing, otherwise calls fn and returns its result (flat map). |
or_else(fn) |
(fn: () -> Option<T>) -> Option<T> |
Returns the option if it is Some, otherwise calls fn and returns its result. |
Code Examples¶
let x = Option.Some(42)
print(x.unwrap()) # 42
let y = Option.Nothing
print(y.unwrap_or(0)) # 0
# Lazy default computation (expensive_default is only called if y is Nothing)
print(y.unwrap_or_else(lambda<int>: 10 + 20)) # 30
# Expect with custom panic message
# y.expect("Value should be present!") # Panics: "Value should be present!"
unwrap() Panics
Only use unwrap() when you are 100% certain the value is Some. In production code, prefer fallback methods like unwrap_or(), unwrap_or_else(), or pattern matching.
Common Use Cases¶
Result Type¶
Result<T, E> represents either success or failure:
# Result<T, E> has two variants:
# - Ok(value) - success with value of type T
# - Err(error) - failure with error of type E
Creating Results¶
# Success
let success: Result<int, str> = Result.Ok(100)
# Failure
let failure: Result<int, str> = Result.Err("Something went wrong")
Pattern Matching¶
let result: Result<int, str> = Result.Ok(100)
match result:
Result.Ok(v): print(f"Success: {v}")
Result.Err(e): print(f"Error: {e}")
Result Methods¶
Desi's Result type provides a complete suite of methods to query, transform, and safely extract values or errors.
| Method | Signature / Return | Description |
|---|---|---|
is_ok() |
() -> bool |
Returns true if the result is Ok. |
is_err() |
() -> bool |
Returns true if the result is Err. |
unwrap() |
() -> T |
Returns the inner value of Ok, or panics if Err. |
unwrap_err() |
() -> E |
Returns the inner error of Err, or panics if Ok. |
unwrap_or(default) |
(default: T) -> T |
Returns the inner value if Ok, otherwise returns the default value. |
unwrap_or_else(fn) |
(fn: (E) -> T) -> T |
Returns the inner value if Ok, otherwise computes it by calling fn(err). |
expect(msg) |
(msg: str) -> T |
Returns the inner value of Ok, or panics with the custom msg and error description if Err. |
expect_err(msg) |
(msg: str) -> E |
Returns the inner error of Err, or panics with the custom msg if Ok. |
ok() |
() -> Option<T> |
Converts the Result<T, E> into an Option<T>, mapping Ok(v) to Some(v) and discarding any error. |
err() |
() -> Option<E> |
Converts the Result<T, E> into an Option<E>, mapping Err(e) to Some(e) and discarding any success. |
map(fn) |
(fn: (T) -> U) -> Result<U, E> |
Maps a Result<T, E> to Result<U, E> by applying fn to the success value. |
and_then(fn) |
(fn: (T) -> Result<U, E>) -> Result<U, E> |
Returns Err if the result is Err, otherwise calls fn on the success value. |
or_else(fn) |
(fn: (E) -> Result<T, F>) -> Result<T, F> |
Returns the result if Ok, otherwise calls fn on the error value. |
Code Examples¶
let x = Result.Ok(100)
print(x.unwrap()) # 100
let y = Result.Err("failed")
print(y.unwrap_err()) # "failed"
print(y.unwrap_or(0)) # 0
# Lazy default computation with error parameter
print(y.unwrap_or_else(lambda<int> err: str: 0)) # 0
# Convert Result to Option
let x_opt = x.ok() # Some(100)
let y_opt = y.err() # Some("failed")
Common Use Cases¶
The ? Operator¶
The ? operator provides concise error propagation:
# For Result<T, E>:
# - If Ok(v), returns v
# - If Err(e), immediately returns Err(e) from the function
# For Option<T>:
# - If Some(v), returns v
# - If Nothing, immediately returns Nothing from the function
Basic Usage¶
def process(input: Result<int, str>) -> Result<int, str>:
let val = input? # Unwraps or early returns Err
return Result.Ok(val * 2)
Without ? operator:
A match arm is an expression, so return goes in front of the whole match:
def process(input: Result<int, str>) -> Result<int, str>:
return match input:
Result.Ok(v): Result.Ok(v * 2)
Result.Err(e): Result.Err(e)
Chaining¶
def complex_operation() -> Result<int, str>:
let x = step1()? # If Err, returns immediately
let y = step2(x)? # If Err, returns immediately
let z = step3(y)? # If Err, returns immediately
return Result.Ok(z)
With Option¶
def find_and_double(items: list<int>, target: int) -> Option<int>:
let index = find_index(items, target)? # Returns Nothing if not found
return Option.Some(items[index] * 2)
Converting Between Types¶
Option to Result¶
def option_to_result<T>(opt: Option<T>, error: str) -> Result<T, str>:
match opt:
Option.Some(v): Result.Ok(v)
Option.Nothing: Result.Err(error)
Result to Option¶
def result_to_option<T, E>(res: Result<T, E>) -> Option<T>:
match res:
Result.Ok(v): Option.Some(v)
Result.Err(_): Option.Nothing
Error Types¶
String Errors¶
Simple approach for quick prototyping:
def risky_operation() -> Result<int, str>:
if condition_fails:
return Result.Err("Something went wrong")
return Result.Ok(42)
Custom Error Types¶
For more complex applications:
A variant is Name: PayloadType — one payload type, not a named field list:
enum FileError:
NotFound: str
PermissionDenied: str
IoError: str
def read_config(path: str) -> Result<str, FileError>:
if not file_exists(path):
return Result.Err(FileError.NotFound(path))
return Result.Ok(read_file(path))
Best Practices¶
✅ Do¶
- Use Option for nullable values: Clearer than null/nil
- Use Result for operations that can fail: File I/O, parsing, network
- Pattern match exhaustively: Handle both variants
- Use ? for error propagation: Reduces boilerplate
- Provide meaningful error messages: Help debugging
❌ Don't¶
- Don't overuse unwrap(): It panics on failure
- Don't ignore errors: Handle or propagate them
- Don't catch everything with _: Be specific about errors
- Don't use exceptions mentality: Errors are values, not exceptions
Common Patterns¶
Default Values¶
def get_or_default(opt: Option<int>, default: int) -> int:
match opt:
Option.Some(v): v
Option.Nothing: default
Built-in map() Method¶
Desi provides a built-in map() method for Option types:
def double(x: int) -> int:
return x * 2
let opt: Option<int> = Option.Some(21)
let mapped = opt.map(double) # Some(42)
let nothing: Option<int> = Option.Nothing
let mapped2 = nothing.map(double) # Nothing
Behavior:
- Some(x).map(f) → Some(f(x)) - transforms the contained value
- Nothing.map(f) → Nothing - preserves Nothing, function is never called
Chaining:
For reference, this is equivalent to:
A callable parameter is typed Any — function types cannot yet be written in a
signature, see Known Limitations:
def map_option<T, U>(opt: Option<T>, f: Any) -> Option<U>:
return match opt:
Option.Some(v): Option.Some(f(v))
Option.Nothing: Option.Nothing
Built-in and_then() Method¶
The and_then() method chains operations that themselves return an Option or Result (also called flatMap or bind):
def get_even(x: int) -> Option[int]:
if x % 2 == 0:
return Option.Some(x)
return Option.Nothing
let opt: Option[int] = Option.Some(4)
let result = opt.and_then(get_even) # Some(4)
let opt2: Option[int] = Option.Some(5)
let result2 = opt2.and_then(get_even) # Nothing (5 is odd)
Key difference from map():
- map(f) wraps the result: Some(x).map(f) -> Some(f(x))
- and_then(f) uses f's return directly: Some(x).and_then(f) -> f(x)
Use when your function already returns an Option/Result:
# Chaining operations that can fail. There is no line-continuation, so the
# chain stays on one line.
let result = get_user(id).and_then(get_profile).and_then(get_settings)
Built-in or_else() Method¶
The or_else() method provides a fallback when the value is Nothing/Err:
def fallback_value() -> Option[int]:
return Option.Some(99)
let opt: Option[int] = Option.Nothing
let result = opt.or_else(fallback_value) # Some(99)
let some_opt: Option[int] = Option.Some(5)
let result2 = some_opt.or_else(fallback_value) # Some(5) - original kept
Key difference from and_then():
- and_then(f) calls f when Some/Ok
- or_else(f) calls f when Nothing/Err
For Result, the function receives the error:
def handle_error(e: str) -> Result[int, str]:
return Result.Ok(0) # Convert error to default value
let res: Result[int, str] = Result.Err("failed")
let result = res.or_else(handle_error) # Ok(0)
Built-in unwrap_or_else() Method¶
The unwrap_or_else() method returns the value or computes a default lazily:
def compute_default() -> int:
return 99
let opt: Option[int] = Option.Nothing
let val = opt.unwrap_or_else(compute_default) # 99 (fn called)
let some_opt: Option[int] = Option.Some(5)
let val2 = some_opt.unwrap_or_else(compute_default) # 5 (fn NOT called)
Comparison with unwrap_or:
- unwrap_or(default) - default is always evaluated
- unwrap_or_else(fn) - fn only called when needed (lazy)
Use lazy evaluation for expensive defaults:
# Expensive - always computed even if unused
let val = opt.unwrap_or(expensive_computation())
# Lazy - only computed if needed
let val = opt.unwrap_or_else(expensive_computation)
The ? Operator (Error Propagation)¶
The ? operator provides concise error propagation. When applied to a Result or Option:
- On Ok/Some: extracts and returns the value
- On Err/Nothing: early returns from the function with the error
def double_or_propagate(res: Result[int, str]) -> Result[int, str]:
let n = res? # If Err, return early with that Err
return Result.Ok(n * 2)
def get_doubled(opt: Option[int]) -> Option[int]:
let n = opt? # If Nothing, return early with Nothing
return Option.Some(n * 2)
[!IMPORTANT] The enclosing function must return a compatible Result/Option type. Using
?in a function that returnsintwill cause a compile error.
Chain Multiple Operations¶
def pipeline(input: int) -> Result<int, str>:
let a = step_a(input)?
let b = step_b(a)?
let c = step_c(b)?
return Result.Ok(c)
Validate Input¶
def validate_age(age: int) -> Result<int, str>:
if age < 0:
return Result.Err("Age cannot be negative")
if age > 150:
return Result.Err("Age seems unrealistic")
return Result.Ok(age)
Comparison with Other Languages¶
| Language | Approach |
|---|---|
| Desi | Option<T>, Result<T,E>, ? operator |
| Python | Exceptions, None |
| Rust | Option<T>, Result<T,E>, ? operator |
| Go | Multiple return values, error interface |
| Java | Exceptions, Optional<T> |
| Swift | Optional<T>, throws |
Desi's approach combines Rust's type safety with Python's readable syntax.
Runtime Limits¶
Desi enforces runtime safety limits to prevent runaway programs:
Recursion Depth¶
Recursion is limited to about 1000 frames by default. Going past it raises a
RuntimeError rather than letting the stack run out:
def infinite_recurse(n: int) -> int:
1 + infinite_recurse(n + 1) # raises after roughly 1000 calls
def main() -> int:
infinite_recurse(0) # RuntimeError: maximum recursion depth exceeded (1000)
0
Without this, exhausting the stack is a hard fault — an access violation the
program cannot report on, because it happens while it is happening. The guard
raises an ordinary error instead, which a try can catch and a supervisor can
survive.
The limit is approximate
What is actually measured is stack space, not frames. A limit of 1000 is a budget of roughly a thousand typical frames, so a function with unusually large frames reaches it sooner and a very small one later.
Measuring the space is the point: frames differ in size, so a count is only a proxy for the thing that runs out. Guarding the space itself is what makes the next section safe.
Configuring the Limit¶
Use set_recursion_limit(n) to adjust the limit at runtime:
def deep_recurse(n: int) -> int:
if n <= 0:
return 0
return 1 + deep_recurse(n - 1)
def main() -> int:
set_recursion_limit(10000) # allow deeper recursion
print(str(deep_recurse(5000)))
0
Raising the limit lets a program recurse deeper. It cannot let it recurse deeper than the stack allows — the limit is clamped to the real end of the stack, so this still raises rather than faulting:
def main() -> int:
set_recursion_limit(10000000) # more frames than any stack holds
infinite_recurse(0) # RuntimeError: out of stack ...
0
The message says which of the two was reached, since "maximum recursion depth exceeded (10000000)" would be the opposite of what happened there.
Tail Call Optimization¶
Desi automatically optimizes self-recursive tail calls:
# Tail recursive - optimized, no stack growth
def factorial(n: int, acc: int) -> int:
if n <= 1:
return acc
return factorial(n - 1, n * acc) # Tail call
def main() -> int:
print(factorial(100000, 1)) # Works without stack overflow
0
Tail position: A call is in tail position when its result is immediately returned without further processing.
See Also¶
- Types - Option and Result type definitions
- Control Flow - Pattern matching with match
- Functions - Return types and early returns