Skip to content

Error Handling

Almide has no exceptions. All errors are values, represented by Result[T, E] and Option[T]. Three postfix operators — !, ??, and ? — provide concise, explicit control over unwrapping.

Result[T, E] represents a computation that can succeed with T or fail with E:

let success = ok(42) // Result[Int, String]
let failure = err("not found") // Result[Int, String]

Handle both cases with match:

match int.parse(input) {
ok(n) => println("parsed: ${int.to_string(n)}"),
err(e) => println("error: ${e}"),
}

The result module provides combinators:

let doubled = result.map(int.parse("42"), (n) => n * 2)
let fallback = result.unwrap_or(int.parse("bad"), 0)
let chained = result.flat_map(int.parse("42"), (n) =>
if n > 0 then ok(n) else err("must be positive")
)

Option[T] represents a value that may or may not exist:

let found = some(42) // Option[Int]
let missing = none // Option[Int]

Common operations:

let name = option.unwrap_or(map.get(config, "name"), "default")
let upper = option.map(map.get(config, "name"), (s) => string.to_upper(s))
let result = option.to_result(map.get(config, "name"), "name is required")

Almide provides three postfix operators for unwrapping Result and Option values. These are the primary way to work with fallible values.

expr! unwraps a Result or Option. If the value is err(e) or none, the enclosing effect fn immediately returns the error. Only valid inside effect fn.

effect fn load_config(path: String) -> Result[String, String] = {
let text = fs.read_text(path)! // unwrap or propagate error
let trimmed = string.trim(text)
ok(trimmed)
}

On Option:

effect fn first_name(users: List[User]) -> Result[String, String] = {
let user = list.first(users)! // none becomes err, propagated
ok(user.name)
}

expr ?? fallback unwraps with a default. If the value is err(_) or none, the fallback is used instead. Valid anywhere (not limited to effect fn).

let port = int.parse(port_str) ?? 8080
let name = map.get(env, "USER") ?? "anonymous"

expr? converts a Result[T, E] to Option[T], discarding the error. On Option, it is a passthrough. Valid anywhere.

let parsed = int.parse(input)? // Result[Int, String] -> Option[Int]
let found = map.get(config, "key")? // Option[String] -> Option[String] (passthrough)
OperatorOn ResultOn OptionValid in
expr!ok(v) -> v, err(e) -> propagatesome(v) -> v, none -> propagateeffect fn only
expr ?? fallbackok(v) -> v, err(_) -> fallbacksome(v) -> v, none -> fallbackAnywhere
expr?ok(v) -> some(v), err(_) -> nonePassthroughAnywhere

Functions with side effects use effect fn. The ! operator is the standard way to propagate errors inside an effect fn:

effect fn process(path: String) -> Result[String, String] = {
let text = fs.read_text(path)! // propagate on error
let data = json.parse(text)! // propagate on error
ok(data)
}

Without !, you would need to manually match on every Result.

When different functions return different error types, use result.map_err combined with ! to convert errors:

type AppError =
| Io(String)
| Parse(String)
effect fn load(path: String) -> Result[Config, AppError] = {
let text = fs.read_text(path)
|> result.map_err(_, (e) => Io(e))!
let raw = json.parse(text)
|> result.map_err(_, (e) => Parse(e))!
ok(parse_config(raw))
}

This pattern replaces the former From convention with explicit, visible error conversion.

guard checks a precondition and exits early when the condition is false:

effect fn validate(age: Int) -> Result[Int, String] = {
guard age >= 0 else err("age cannot be negative")
guard age <= 150 else err("age seems unrealistic")
ok(age)
}

guard is Almide’s replacement for early return. There is no return keyword.

Guard with a block body for complex exit logic:

effect fn process(path: String) -> Result[Unit, String] = {
guard fs.exists?(path) else {
println("file not found, skipping")
ok(())
}
let content = fs.read_text(path)!
println(content)
ok(())
}
LayerMechanismUse case
Normal failureResult[T, E]Parse, validate, I/O, lookup
Programmer errorpanicUnreachable code, invariant violations
Testingassert_eq, assertTest assertions

Exceptions do not exist. There is no throw or catch.

When you need to handle success and failure differently:

match int.parse(input) {
ok(n) if n > 0 => process(n),
ok(_) => err("must be positive"),
err(e) => err("invalid input: ${e}"),
}

When you need to validate before proceeding:

effect fn create_user(name: String, age: Int) -> Result[User, String] = {
guard string.len(name) > 0 else err("name is required")
guard age >= 0 else err("age must be non-negative")
ok({ name, age })
}

When a missing value has a sensible default:

let port = int.parse(port_str) ?? 8080
let name = map.get(env, "USER") ?? "anonymous"

When each step can fail and depends on the previous:

let config = int.parse(port_str)
|> result.flat_map(_, (port) =>
if port > 0 and port < 65536
then ok(port)
else err("port out of range")
)