Error Handling
Operations that can fail return Result values. Hot makes working with Results ergonomic through automatic wrapping, automatic unwrapping, and lazy argument evaluation.
The Result Type
A Result represents either success (Ok) or failure (Err):
Result enum {
Ok(Any),
Err(Any)
}
Return values are automatically wrapped in Result.Ok, so you typically only need err() to signal failures:
safe-divide fn (a: Int, b: Int): Int {
if(eq(b, 0), err("Division by zero"), div(a, b)) // div result auto-wrapped in Ok
}
Many core functions return Results implicitly—HTTP calls, file operations, parsing, and other fallible operations.
Automatic Unwrapping
When you use a Result value as a function argument or interpolate it in a template, Hot automatically handles it:
- Ok Result: Unwraps to the inner value
- Err Result: Immediately halts execution
// HTTP functions return Results automatically
response http-get("https://api.example.com/user/1")
name response.body.name // Auto-unwraps the Result
greeting `Hello, ${name}!`
If the HTTP call failed, execution halts at the point of use—you don't need explicit error handling on every line. Errors automatically propagate up.
Note: Function return type annotations specify the expected success type, not
Result. The Result wrapper is implicit for any operation that can fail.
Dot Access on Results
Auto-unwrapping extends to field access. Dot access on an Ok Result reads fields from the payload, so you never need to unwrap before drilling in:
response http-get("https://api.example.com/user/1") // returns a Result
name response.body.name // reads .body.name from the Ok payload
If the Result is an Err, the dot access halts execution at that point — the same propagation rule as passing an Err to a function.
Checking Results Explicitly
Use is-ok and is-err to inspect Results without triggering automatic unwrapping:
result-check safe-divide(10, 0)
message-check if(is-ok(result-check),
`Result: ${result-check}`,
"Cannot divide by zero") // This branch runs
safe-divide(10, 0) → Result.Err("Division by zero")
message-check → "Cannot divide by zero"
These functions receive the Result as a lazy argument, which prevents automatic unwrapping during the check.
You can also use match for pattern matching on Result variants:
result safe-divide(20, 4)
message match result {
Result.Ok => `Success: ${result}`
Result.Err => `Error: ${result}`
}
Lazy Arguments and Result Checking
When a function argument is marked lazy, it isn't evaluated until explicitly requested. This is how Hot enables safe Result inspection.
// The if function uses lazy arguments
if fn cond (pred: Any, lazy then: Any, lazy else: Any): Any {
pred => { do then }
=> { do else }
}
For lazy arguments, Result checking is suppressed during evaluation. This means:
- You can pass expressions that produce Results
- The Result won't auto-unwrap (or fail) until
doevaluates it - Functions like
is-okandis-errcan safely receive and inspect Results
// Safe division that returns a Result
safe-divide fn (a, b) {
if(eq(b, 0), err("Division by zero"), ok(div(a, b)))
}
// is-ok receives the Result without triggering auto-unwrap
result safe-divide(10, 0)
if(is-ok(result),
`Result: ${result}`,
"Cannot divide by zero") // This branch runs
Writing Your Own Result-Inspecting Functions
The same rule applies to your own functions. A regular parameter auto-unwraps its argument, so passing an Err Result to it triggers the halt before your function body runs. Mark the parameter lazy and evaluate it with do — inside a lazy context, do preserves the Result instead of unwrapping it:
// ❌ Regular parameter: an Err argument halts before the body executes
describe fn (r: Any): Str {
if(is-err(r), "failed", "succeeded") // never reached for Err values
}
describe(err("boom")) // Runtime error: boom
// ✅ lazy parameter + do: receives and inspects the Result safely
describe fn (lazy r: Any): Str {
v do r
if(is-err(v), "failed", "succeeded")
}
describe(err("boom")) // "failed"
describe(ok(42)) // "succeeded"
This composes with OnErr.Preserve (see Pattern 5) to classify per-item outcomes without halting the batch:
results map(items, process-item(%), OnErr.Preserve) // Errs stay as values
labels map(results, describe(%)) // ["succeeded", "failed", ...]
If a halt fires when you hand a preserved Result.Err to a helper function, the fix is almost always marking the receiving parameter lazy.
Short-Circuit Evaluation
Lazy arguments also enable short-circuit evaluation for and and or:
// Short-circuit prevents errors in unevaluated branches
x-val 0
short-result if(eq(x-val, 0), "zero", div(10, x-val)) // div never called, no error
Error Handling Patterns
Pattern 1: Let It Fail
For many cases, just use Results directly. Errors propagate automatically:
main fn () {
user fetch-user(id) // Auto-unwraps or fails
posts fetch-posts(user.id) // Auto-unwraps or fails
render-page(user, posts) // Only runs if both succeeded
}
Pattern 2: Check and Handle
When you need to handle errors explicitly:
result fetch-user(id)
if(is-ok(result),
render-profile(result),
render-error-page(result))
Or use match for cleaner syntax:
result fetch-user(id)
match result {
Result.Ok => render-profile(result)
Result.Err => render-error-page(result)
}
Pattern 3: Default Values
Provide fallbacks for failures:
// Provide fallbacks for failures
config-result safe-divide(10, 0)
config if(is-ok(config-result), config-result, 99) // Fallback to 99
Pattern 4: Fail on Broken Invariants
Use fail to declare that the current run or task hit a bug or broken
invariant and must stop. This is different from returning a normal domain
err(...) value: expected failures — bad input, a refused connection, a
query error — should be err(...) values the caller can branch on, while
fail() and cancel() halt execution and surface at the run or task
boundary (the run:fail event, or status: "failed" on the TaskResult
returned by ::hot::task/await).
apply-migration fn (db, version: Int) {
if(lt(version, current-version(db)),
fail("migration version went backwards", {version: version}),
run-migration(db, version))
}
Pattern 5: Preserve Domain Errors in Map-Shaped Calls
Eligible higher-order functions force a normal Result.Err by default. Pass
OnErr.Preserve when you intentionally want to keep per-item domain errors as
values in the result:
scores map([1, 0, 3], load-score, OnErr.Preserve) // keep Err slots as values
failed filter(scores, is-err) // [Err("missing score")]
OnErr applies only to normal err(...) / Result.Err(...) values. It does
not catch fail(), cancel(), or hard runtime errors.
The Error Payload Convention
Keep payloads in one of two shapes so error text survives every hop:
- Simple: a plain
Str—err("connection refused"). - Structured: a Map with a
messagefield plus any structured fields —err({message: "pg: relation missing", code: "42P01"}).
The auto-unwrap halt reads message (then msg) from Map payloads, and
err-message(result) extracts readable text from any shape — including
a halt's Failure payload — so handlers never hand-roll extraction:
conn ::pg/connect(opts)
if-err(conn, (e) { log(`db down: ${err-message(e)}`) })
Pattern 6: Chain Fallible Steps
if-ok flat-maps: the handler receives the Ok value, its return passes
through unchanged, and an Err short-circuits out. Use it to chain steps
where each depends on the previous one succeeding:
greeting if-ok(open-conn("db.example"), (conn) {
send-greeting(conn)
})
// greeting = "greeted db.example"
chain-err if-ok(open-conn("down.example"), (conn) {
send-greeting(conn) // never runs; the Err short-circuits out
})
// is-err(chain-err) = true
The first failing step becomes the whole chain's return value, as a single
well-formed Err.
Pattern 7: Supervise Untrusted Work with Tasks
There is no catch in Hot. Code that must survive a fail() in work it
does not control — arbitrary user callbacks, independent jobs — runs that
work as a task. A halt inside the task never propagates to the caller; it
surfaces as data on the awaited result:
info ::hot::task/start(::myapp/risky-job, args)
result ::hot::task/await(info.id)
if(eq(result.status, "failed"),
record-error("job", result.result),
use-value(result.result))
Note:
::hot::lang/tryand::hot::lang/try-callwere removed in Hot 2.6.0. Old code that wrapped calls intryto detect failures should branch on the returnedResultdirectly (Pattern 2); fan-out loops that usedtryfor isolation should passOnErr.Preserve(Pattern 5).
Summary
- Use
Result.Ok(value)orok(value)andResult.Err(message)orerr(message)to create Results - Results auto-unwrap when passed to functions or used in templates
- Err Results automatically fail at point of use, carrying the payload's message—no explicit handling needed
- Use
is-ok(result)andis-err(result)to check without triggering auto-unwrap - Use
if-okto chain fallible steps; anErrshort-circuits the chain - Use
OnErr.Preservewith eligible map-shaped APIs when you intentionally want to keep domain errors as values - Use
fail()/cancel()for bugs and broken invariants, not ordinary recoverable domain errors - Supervise untrusted or independent work with a task boundary (
::hot::task/start+await) - Use
matchfor pattern matching onResult.OkandResult.Errvariants - Dot access on Results automatically accesses fields within the payload:
result.name - Lazy arguments suppress Result checking, enabling safe inspection and short-circuit evaluation
- Most code can ignore error handling; errors propagate automatically