Handle errors

Chapter 8 · Define error values, handle failures, and understand inferred error effects.

BAML makes a big bet on error handling: errors are ordinary values, and the compiler tracks how they flow through your program so the syntax stays simple.

You can see the difference in two places: how you write a function that can fail, and how you call it.

Error handling across languages

Each example checks a tool name: a valid name is returned as a string, and an empty name produces a BadToolInput value. The caller formats a valid name as Tool: search. The recovery example returns the error’s message when validation fails. Choose a language to see both sides of the pattern.

Write a function that can fail

Thrown errors aren’t recorded: ): string. Return the value: return toolThrown errors aren’t recorded: ): string. Return the value: return tool

New concepts introduced

WordWhat it means
ErrorBase class for an exception.
throwStop the function and send an exception to the caller.

Call without handling the error

Use the returned value: `Tool: ${checkToolName(tool)}`Use the returned value: `Tool: ${checkToolName(tool)}`

No new error-handling concepts. Exceptions propagate through the call.

Handle the error

Wrap the call in try: try {
    return `Tool: ${checkToolName(tool)}`. Same successful expression: `Tool: ${checkToolName(tool)}`. Check the error in catch: } catch (error: unknown) {
    if (error instanceof BadToolInput) {
      return error.message
    }
    throw error
  }Wrap the call in try: try {
    return `Tool: ${checkToolName(tool)}`. Same successful expression: `Tool: ${checkToolName(tool)}`. Check the error in catch: } catch (error: unknown) {
    if (error instanceof BadToolInput) {
      return error.message
    }
    throw error
  }

New concepts introduced

WordWhat it means
tryEnclose code whose exceptions you want to handle.
catchReceive an exception thrown inside try.
unknownRequire a type check before using the caught value.
instanceofCheck whether the caught value is a BadToolInput.

In BAML

The BAML excerpts through error context add to one project. The lambda example repeats BadToolInput for context; keep the existing declaration when adding it. The final checked failure is a separate project.

Write a function that can fail

BAMLmain.baml
Compiler tracks this error: throw BadToolInput. Return the value: toolCompiler tracks this error: throw BadToolInput. Return the value: tool

New concepts introduced

WordWhat it means
throwStop the function and send an error value to the caller.
Inferred throwsThe compiler tracks errors from the body and its calls.

Call a function that can fail

Call check_tool_name without a handler:

BAMLmain.baml
Use the returned value: `Tool: ${check_tool_name(tool)}`Use the returned value: `Tool: ${check_tool_name(tool)}`

No new error-handling concepts. Errors propagate through the call.

Handle the error

Append catch to return the error’s message instead:

BAMLmain.baml
Check that no error escapes: throws never. Same successful expression: `Tool: ${check_tool_name(tool)}`. Return a fallback: catch (e) {
        BadToolInput => e.message,
    }Check that no error escapes: throws never. Same successful expression: `Tool: ${check_tool_name(tool)}`. Return a fallback: catch (e) {
        BadToolInput => e.message,
    }

New concepts introduced

WordWhat it means
Postfix catchHandle errors from the preceding expression.
throws neverAsk the compiler to reject any error that could escape.

Compare the patterns

Compare the signatures

LanguageWritten return typeWhere the error is tracked
TypeScriptstringNot part of the function type
Effect.tsEffect<string, BadToolInput, never>In the Effect type
RustResult<String, BadToolInput>In the Result type
BAMLstringIn the inferred throws BadToolInput effect

Compare the callers

LanguageHandling constructReturn type after recovery
TypeScripttry / catch with instanceofstring; other errors are rethrown
Effect.tsEffect.catchAllEffect<string, never, never>
Rustmatch on Ok / ErrString
BAMLExpression-level catchstring, with throws never checked

Rust's Result<T, E> and Effect.ts's typed error channel shaped this model. BAML keeps the successful return value unwrapped and represents possible failures as an inferred error effect.

This page explains how to:

  • Define and throw structured error values.
  • Inspect errors inferred by the compiler.
  • Handle selected errors or every error.
  • Enforce an explicit throws contract.
  • Distinguish errors from panics.
  • Understand error inference for lambdas and function-valued parameters.

Add another error value

The first BAML example used BadToolInput for invalid input. Add a second class for a temporary outage:

BAMLmain.baml
class ToolUnavailable {
    tool: string,
    retry_after_ms: int,
}
class ToolUnavailable {
    tool: string,
    retry_after_ms: int,
}

BadToolInput and ToolUnavailable let a caller distinguish invalid input from a temporary outage. Their fields let the caller choose a response without parsing an error message.

Throw an error value

Use throw to stop the current evaluation and send an error value to the caller:

BAMLmain.baml
function invoke_tool(tool: string) -> string {
    if (tool == "") {
        throw BadToolInput { tool, message: "tool name is empty" };
    }
    if (int.random(0, 100) < 20) {
        throw ToolUnavailable { tool, retry_after_ms: 500 };
    }
    `result from ${tool}`
}
function invoke_tool(tool: string) -> string {
    if (tool == "") {
        throw BadToolInput { tool, message: "tool name is empty" };
    }
    if (int.random(0, 100) < 20) {
        throw ToolUnavailable { tool, retry_after_ms: 500 };
    }
    `result from ${tool}`
}

On success, invoke_tool returns the final string. An empty name produces BadToolInput, while a random value below 20 models a 20% chance of ToolUnavailable. In either case, throw stops the function before it reaches the final expression.

int.random(0, 100) returns an integer from 0 through 99. Its API can also throw baml.errors.InvalidArgument when the range is invalid, so the compiler carries that declared error even though these literal bounds are valid.

Inspect inferred errors

When a function omits throws, BAML infers the errors that can leave its body. Omission doesn't mean throws never.

Your editor shows the effective signature when you hover over the function. Line numbers in the terminal examples refer to the complete source project, including its excerpt markers. From a terminal, baml describe prints the declaration and the places that use it:

$ baml describe invoke_tool | head -12
function invoke_tool  baml_src/main.baml:36-44

function invoke_tool(tool: string) -> string {
    if (tool == "") {
        throw BadToolInput { tool, message: "tool name is empty" };
    }
    if (int.random(0, 100) < 20) {
        throw ToolUnavailable { tool, retry_after_ms: 500 };
    }
    `result from ${tool}`
}

The LSP reports this effective signature:

Inferred signature (LSP hover)
function invoke_tool(tool: string) -> string throws baml.errors.InvalidArgument | BadToolInput | ToolUnavailable

string is the success type. baml.errors.InvalidArgument | BadToolInput | ToolUnavailable is the error type. The compiler inferred the two errors thrown by the function and propagated the error declared by int.random.

For example, uncaught also omits throws and doesn't handle the call:

BAMLmain.baml
function uncaught() -> string {
    invoke_tool("search")
}
function uncaught() -> string {
    invoke_tool("search")
}

The error effect follows the call:

invoke_tool
    ├─ returns string ──────────────────────> uncaught returns string
    ├─ throws ToolUnavailable ──────────────> uncaught may throw ToolUnavailable
    ├─ throws BadToolInput ─────────────────> uncaught may throw BadToolInput
    └─ throws InvalidArgument ───────────────> uncaught may throw InvalidArgument

If no caller handles the value, the runtime reports the value and the path it took:

error: Traceback (most recent call last):
  File "baml_src/main.baml", line 220, in user.uncaught
  File "baml_src/main.baml", line 41, in user.invoke_tool
uncaught throw: user.ToolUnavailable {tool: "search", retry_after_ms: 500}

Handle one error

Add catch after the expression that can throw. Its arms look like match or switch cases: BAML tests each pattern and narrows the bound value to the matching type.

Unlike match, catch is intentionally non-exhaustive. You can handle the error that matters here and leave the rest for the caller:

BAMLmain.baml
function handle_bad_input(
    tool: string,
) -> string throws ToolUnavailable | baml.errors.InvalidArgument {
    invoke_tool(tool) catch (e) {
        BadToolInput => `invalid tool input: ${e.message}`,
    }
}
function handle_bad_input(
    tool: string,
) -> string throws ToolUnavailable | baml.errors.InvalidArgument {
    invoke_tool(tool) catch (e) {
        BadToolInput => `invalid tool input: ${e.message}`,
    }
}

catch (e) binds the thrown value. In the BadToolInput arm, e is a BadToolInput, so you can read its message field. An unmatched ToolUnavailable or baml.errors.InvalidArgument continues to the caller, which is why both remain in the function's throws contract.

Handle every error

Use catch_all when the call site promises to handle the complete error set. This handler gives each current error its own response:

BAMLmain.baml
function run_step(tool: string) -> string throws never {
    invoke_tool(tool) catch_all (e) {
        ToolUnavailable => `retry ${e.tool} in ${e.retry_after_ms} ms`,
        BadToolInput => `invalid tool input: ${e.message}`,
        baml.errors.InvalidArgument => e.message,
    }
}
function run_step(tool: string) -> string throws never {
    invoke_tool(tool) catch_all (e) {
        ToolUnavailable => `retry ${e.tool} in ${e.retry_after_ms} ms`,
        BadToolInput => `invalid tool input: ${e.message}`,
        baml.errors.InvalidArgument => e.message,
    }
}

catch_all must be exhaustive. If invoke_tool gains another error type, this function stops compiling until you add an arm. That keeps its throws never promise intact during refactors and agent-driven changes.

Use _ when the boundary should handle present and future errors the same way:

BAMLmain.baml
function run_step_with_fallback(tool: string) -> string throws never {
    invoke_tool(tool) catch_all (e) {
        BadToolInput => `invalid tool input: ${e.message}`,
        _ => "tool failed",
    }
}
function run_step_with_fallback(tool: string) -> string throws never {
    invoke_tool(tool) catch_all (e) {
        BadToolInput => `invalid tool input: ${e.message}`,
        _ => "tool failed",
    }
}

BadToolInput keeps its specific response. The _ arm makes the handler exhaustive by covering ToolUnavailable, baml.errors.InvalidArgument, and any error types added later. Use a wildcard only when that future behavior is the policy you want.

Enforce an error contract

Add a throws clause to constrain the errors that a function can expose:

BAMLmain.baml
function invoke_tool_checked(
    tool: string,
) -> string throws ToolUnavailable | BadToolInput | baml.errors.InvalidArgument {
    invoke_tool(tool)
}
function invoke_tool_checked(
    tool: string,
) -> string throws ToolUnavailable | BadToolInput | baml.errors.InvalidArgument {
    invoke_tool(tool)
}

The compiler compares errors inferred from the body with the declared union. If an implementation or an agent-driven edit exposes a different error, the contract fails to compile. The function must handle the new error or make the API change explicit by updating throws.

An exhaustive catch_all protects a caller when a callee changes. A throws contract protects a function's callers from an accidental expansion of its public error set.

Use throws never when no error value can leave a function. The reference section includes a checked failure for this contract.

Distinguish errors from panics

Use an error for a failure that a caller can inspect and potentially handle, such as invalid input or a temporary outage. Errors appear in the inferred or declared throws set.

Use a panic when execution reaches a path that isn't ready or a state the program considers invalid. During development, a panic can mark a placeholder implementation while the rest of the program continues to type-check and unrelated tests continue to run.

This function validates its input, then marks the unfinished implementation with a panic:

BAMLmain.baml
function load_tool_config(tool: string) -> string {
    if (tool == "") {
        throw BadToolInput { tool, message: "tool name is empty" };
    }
    baml.sys.panic(`TODO: load config for ${tool}`)
}
function load_tool_config(tool: string) -> string {
    if (tool == "") {
        throw BadToolInput { tool, message: "tool name is empty" };
    }
    baml.sys.panic(`TODO: load config for ${tool}`)
}

The effective signature includes BadToolInput but not the possible panic. Panics aren't part of the typed error effect. baml describe shows the declaration directly:

$ baml describe load_tool_config | head -9
function load_tool_config  baml_src/main.baml:114-119

function load_tool_config(tool: string) -> string {
    if (tool == "") {
        throw BadToolInput { tool, message: "tool name is empty" };
    }
    baml.sys.panic(`TODO: load config for ${tool}`)
}

In the editor, LSP hover adds the inferred effect: function load_tool_config(tool: string) -> string throws BadToolInput.

catch_all handles error values, not panics. Calling panic_escapes_catch_all("search") still stops at the placeholder:

BAMLmain.baml
function panic_escapes_catch_all(tool: string) -> string {
    load_tool_config(tool) catch_all (e) {
        _ => "caught error",
    }
}
function panic_escapes_catch_all(tool: string) -> string {
    load_tool_config(tool) catch_all (e) {
        _ => "caught error",
    }
}

Catch errors and panics at a boundary

Use catch_all_panics when a boundary must receive both errors and panics. For example, a web request handler can return a 500 response instead of taking down the server process:

BAMLmain.baml
function handle_web_request(tool: string) -> int {
    {
        load_tool_config(tool);
        200
    } catch_all_panics (e) {
        _ => 500,
    }
}
function handle_web_request(tool: string) -> int {
    {
        load_tool_config(tool);
        200
    } catch_all_panics (e) {
        _ => 500,
    }
}

In a real server, this boundary can also log the error context before returning 500. Use this broader handler only when the boundary can restore a valid state or stop cleanly. Catching a panic stops it from propagating; it doesn't implement the unfinished path or repair an invalid condition.

Log an error before returning a fallback

A catch arm can use a block when it needs to do work before producing its value. This boundary records the error, then returns a stable fallback:

BAMLmain.baml
function run_step_with_logging(tool: string) -> string throws never {
    invoke_tool(tool) catch_all (e) {
        _ => {
            log.error(e);
            "tool failed"
        },
    }
}
function run_step_with_logging(tool: string) -> string throws never {
    invoke_tool(tool) catch_all (e) {
        _ => {
            log.error(e);
            "tool failed"
        },
    }
}

log.error accepts the error value directly. Run the program with --log error to emit error-level logs. The block's final expression, "tool failed", is the value of the catch arm.

Advanced error reference

Read error context

Every catch variant has two binding forms:

  • catch (e) and catch (e, context).
  • catch_all (e) and catch_all (e, context).
  • catch_all_panics (e) and catch_all_panics (e, context).

Use the second form when a handler needs a stack trace or cause chain:

BAMLmain.baml
function with_context() -> string throws never {
    invoke_tool("search") catch (e, tb) {
        ToolUnavailable => `${e.tool}: ${tb.stack_trace.frames.length()} frame(s)`,
        BadToolInput => `${e.tool}: ${tb.stack_trace.frames.length()} frame(s)`,
        baml.errors.InvalidArgument => e.message,
    }
}
function with_context() -> string throws never {
    invoke_tool("search") catch (e, tb) {
        ToolUnavailable => `${e.tool}: ${tb.stack_trace.frames.length()} frame(s)`,
        BadToolInput => `${e.tool}: ${tb.stack_trace.frames.length()} frame(s)`,
        baml.errors.InvalidArgument => e.message,
    }
}

The name tb isn't special. In this example, it refers to the ErrorContext and reads its stack_trace.

ErrorContext reference

The second catch binding has type ErrorContext:

  • error: The caught error value.
  • stack_trace: The stack trace captured for the error.
  • cause: An optional preceding ErrorContext in a cause chain.
  • root_cause(): Returns the deepest context in the cause chain.

The binding name doesn't change the type. catch (error, context) and catch (e, tb) use the same two-binding form.

Lambda error inference

A lambda owns the errors thrown by its body. Defining a throwing lambda doesn't make the enclosing function throw; calling the lambda does.

Keep the error type close to the lambda that uses it:

BAMLmain.baml
class BadToolInput {
    tool: string,
    message: string,
}

function define_validator() -> string throws never {
    let validate = (score: int) -> int {
        if (score < 0) {
            throw BadToolInput { tool: "rank", message: "score is negative" };
        }
        score
    };
    "validator ready"
}
class BadToolInput {
    tool: string,
    message: string,
}

function define_validator() -> string throws never {
    let validate = (score: int) -> int {
        if (score < 0) {
            throw BadToolInput { tool: "rank", message: "score is negative" };
        }
        score
    };
    "validator ready"
}

The compiler infers BadToolInput from the lambda body. define_validator creates the lambda but doesn't call it, so its own throws never contract remains true.

Function-type error inference

When a function-valued parameter omits throws, BAML creates an implicit error-effect variable for that parameter.

Call a supplied validator and propagate its errors:

BAMLmain.baml
function apply_validator(validate: (score: int) -> int, score: int) -> int {
    validate(score)
}

function validate_score(score: int) -> int {
    apply_validator(
        (value: int) -> int {
            if (value < 0) {
                throw BadToolInput { tool: "rank", message: "score is negative" };
            }
            value
        },
        score,
    )
}
function apply_validator(validate: (score: int) -> int, score: int) -> int {
    validate(score)
}

function validate_score(score: int) -> int {
    apply_validator(
        (value: int) -> int {
            if (value < 0) {
                throw BadToolInput { tool: "rank", message: "score is negative" };
            }
            value
        },
        score,
    )
}

Use baml describe to inspect either declaration in readable form:

$ baml describe apply_validator
function apply_validator  baml_src/main.baml:190-192

function apply_validator(validate: (score: int) -> int, score: int) -> int {
    validate(score)
}

references (1):
  baml_src/main.baml:195  apply_validator(

LSP hover makes the inferred effect parameter visible:

Inferred signature (LSP hover)
function apply_validator(validate: (score: int) -> int throws callback, score: int) -> int throws callback
function validate_score(score: int) -> int throws BadToolInput

callback is the compiler's name for the implicit effect variable. At this call site, the lambda binds it to BadToolInput. Another function value can bind it to another error set or to never.

Throws contract diagnostic

This checked failure promises throws never but calls a function that can throw ToolUnavailable:

BAMLmain.baml
class ToolUnavailable {
    tool: string,
    retry_after_ms: int,
}


function invoke_tool() -> string throws ToolUnavailable {
    throw ToolUnavailable { tool: "search", retry_after_ms: 500 }
}

function main() -> string throws never {
    invoke_tool()
}
class ToolUnavailable {
    tool: string,
    retry_after_ms: int,
}


function invoke_tool() -> string throws ToolUnavailable {
    throw ToolUnavailable { tool: "search", retry_after_ms: 500 }
}

function main() -> string throws never {
    invoke_tool()
}
E0096

  × declared throws is `never`, but this function may also throw `ToolUnavailable`
    ╭─[main.baml:21:5]
 21 │     invoke_tool()
    ·     ───────────
    ╰────

Handle ToolUnavailable, add it to the declared contract, or remove the throws clause and use inference.