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:

  1. You can pass expressions that produce Results
  2. The Result won't auto-unwrap (or fail) until do evaluates it
  3. Functions like is-ok and is-err can 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 Strerr("connection refused").
  • Structured: a Map with a message field 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/try and ::hot::lang/try-call were removed in Hot 2.6.0. Old code that wrapped calls in try to detect failures should branch on the returned Result directly (Pattern 2); fan-out loops that used try for isolation should pass OnErr.Preserve (Pattern 5).

Summary

  • Use Result.Ok(value) or ok(value) and Result.Err(message) or err(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) and is-err(result) to check without triggering auto-unwrap
  • Use if-ok to chain fallible steps; an Err short-circuits the chain
  • Use OnErr.Preserve with 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 match for pattern matching on Result.Ok and Result.Err variants
  • 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