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.

Binding vs Consuming a Result

Merely binding a Result preserves the tagged value. Propagation happens when ordinary code consumes it:

result fetch-user(id) // Result remains intact in this binding
valid is-ok(result)   // inspected by a lazy Result-aware function
page render(result)   // Ok unwraps; Err halts and propagates here

Ordinary function arguments, template interpolation, and ordinary field or index access are consumption boundaries. is-ok and is-err inspect the wrapper without consuming it. if-ok and if-err pass the selected variant's unwrapped payload to a handler, then return a Result; the unmatched variant passes through unchanged. match also handles Result variants explicitly, as described in Checking Results Explicitly.

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. Use is-ok, is-err, or match when code needs to inspect the Result without consuming it.

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}`
}

The match chooses an arm using the Result.Ok or Result.Err tag. Within the selected arm, result evaluates to that variant's payload.

Lazy Arguments and Result Checking

When a function parameter is marked lazy, its argument expression is deferred until the function forces it with do. 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 }
}

When a lazy argument is forced, ordinary Result consumption remains suppressed inside that lazy context. This means:

  1. The argument is not evaluated or consumed at the ordinary call boundary
  2. do forces the expression inside the lazy context and preserves any Result
  3. The function can inspect that Result without auto-unwrapping or propagating it
// 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) { println(`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))

This server-side wait is appropriate because the same Hot execution consumes the result. When a client starts independent background work, return info.id instead and use the SDK task waiter. That waiter follows the durable task state without keeping the originating run alive.

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
  • Binding a Result preserves it; ordinary consumption triggers unwrapping or propagation
  • 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 work with a task boundary; await it in Hot only when the same execution needs the result
  • Return independent task ids to clients and wait with the SDK task resource
  • 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