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
New concepts introduced
| Word | What it means |
|---|---|
Error | Base class for an exception. |
throw | Stop the function and send an exception to the caller. |
Call without handling the error
No new error-handling concepts. Exceptions propagate through the call.
Handle the error
New concepts introduced
| Word | What it means |
|---|---|
try | Enclose code whose exceptions you want to handle. |
catch | Receive an exception thrown inside try. |
unknown | Require a type check before using the caught value. |
instanceof | Check whether the caught value is a BadToolInput. |
Write a function that can fail
New concepts introduced
| Word | What it means |
|---|---|
Data.TaggedError | Define an error class with a named tag. |
Effect<A, E, R> | A computation’s success type, error type, and required services, in that order. |
Effect.fail | Create a computation that fails with an error. |
Effect.succeed | Create a computation that succeeds with a value. |
never | In the requirements position: no required services. |
Call without handling the error
New concepts introduced
| Word | What it means |
|---|---|
Effect.gen | Build an Effect computation from a generator. |
function* | Declare a JavaScript generator function. |
yield* | Read an Effect’s successful value, or propagate its failure. |
Handle the error
New concepts introduced
| Word | What it means |
|---|---|
.pipe | Pass the computation through operators, in order. |
Effect.catchAll | Handle expected errors with a fallback computation. |
never in the error position | No expected errors remain in the computation’s type. |
Write a function that can fail
New concepts introduced
| Word | What it means |
|---|---|
Result<T, E> | A return value that contains either success or an error. |
Ok(value) | The successful variant of a Result. |
Err(error) | The error variant of a Result. |
Call without handling the error
New concepts introduced
| Word | What it means |
|---|---|
? | Extract success or return the error; requires a compatible return type. |
Handle the error
New concepts introduced
| Word | What it means |
|---|---|
match | Select a branch by variant; all variants must be covered. |
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
New concepts introduced
| Word | What it means |
|---|---|
throw | Stop the function and send an error value to the caller. |
Inferred throws | The compiler tracks errors from the body and its calls. |
Call a function that can fail
Call check_tool_name without a handler:
No new error-handling concepts. Errors propagate through the call.
Handle the error
Append catch to return the error’s message instead:
New concepts introduced
| Word | What it means |
|---|---|
Postfix catch | Handle errors from the preceding expression. |
throws never | Ask the compiler to reject any error that could escape. |
Compare the patterns
Compare the signatures
| Language | Written return type | Where the error is tracked |
|---|---|---|
| TypeScript | string | Not part of the function type |
| Effect.ts | Effect<string, BadToolInput, never> | In the Effect type |
| Rust | Result<String, BadToolInput> | In the Result type |
| BAML | string | In the inferred throws BadToolInput effect |
Compare the callers
| Language | Handling construct | Return type after recovery |
|---|---|---|
| TypeScript | try / catch with instanceof | string; other errors are rethrown |
| Effect.ts | Effect.catchAll | Effect<string, never, never> |
| Rust | match on Ok / Err | String |
| BAML | Expression-level catch | string, 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
throwscontract. - 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:
main.bamlclass 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:
main.bamlfunction 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:
The LSP reports this effective signature:
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:
main.bamlfunction uncaught() -> string {
invoke_tool("search")
}function uncaught() -> string {
invoke_tool("search")
}The error effect follows the call:
If no caller handles the value, the runtime reports the value and the path it took:
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:
main.bamlfunction 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:
main.bamlfunction 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:
main.bamlfunction 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:
main.bamlfunction 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:
main.bamlfunction 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:
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:
main.bamlfunction 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:
main.bamlfunction 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:
main.bamlfunction 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)andcatch (e, context).catch_all (e)andcatch_all (e, context).catch_all_panics (e)andcatch_all_panics (e, context).
Use the second form when a handler needs a stack trace or cause chain:
main.bamlfunction 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 precedingErrorContextin 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:
main.bamlclass 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:
main.bamlfunction 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:
LSP hover makes the inferred effect parameter visible:
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:
main.bamlclass 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()
}Handle ToolUnavailable, add it to the declared contract, or remove the throws clause and use inference.