beps/docs/proposals/BEP-001-exceptions/02_learn.md
This document covers common error handling scenarios in BAML.
<!-- TOC_PLACEHOLDER -->Attach a catch block to the expression:
let user = GetUser(id) catch {
e => null
}
The pattern e matches any error and binds it to the variable e. The variable user will be either the result of GetUser(id) or null if it threw.
Attach catch directly after the function body:
function ExtractResume(text: string) -> Resume | null {
client "gpt-4o"
prompt #"Extract resume from {{ text }}"#
} catch {
e => null
}
The catch block attaches to the function itself. No need to wrap the body in a try block.
Use inline catch with a fallback value:
let score = GetScore(resume) catch { e => 0 }
let name = user.name catch { e => "Unknown" }
Nest catch blocks:
let config = LoadFromCache(id) catch {
e => LoadFromDB(id) catch {
e => DefaultConfig()
}
}
Each fallback is tried in order. If LoadFromCache fails, try LoadFromDB. If that fails, use DefaultConfig().
catch bind in complex expressions?catch binds loosely—it applies to the entire preceding expression:
a + b catch { e => 0 } // Parses as: (a + b) catch { e => 0 }
Foo().bar catch { e => null } // Parses as: (Foo().bar) catch { e => null }
Use parentheses to limit scope:
a + (b catch { e => 0 }) // Only 'b' is caught, then added to 'a'
They propagate to the caller. You don't need to list every error type:
function Process(text: string) -> Result {
let data = Parse(text) // Can throw ParseError
Transform(data) // Can throw TransformError
} catch {
e: ParseError => DefaultResult()
// TransformError is not handled here - it propagates up
}
The compiler implicitly re-throws unhandled errors. This is equivalent to:
} catch {
e: ParseError => DefaultResult()
__other => throw __other // Added by compiler
}
Use a block in the handler to perform actions before throwing:
Process(data) catch {
e => {
log.error("Processing failed", e)
throw e
}
}
Attach catch to the loop:
for (url in urls) {
let data = Fetch(url)
results.append(data)
} catch {
e => log.warn("Failed to fetch", e)
// Continues to next iteration
}
When an error occurs, the handler runs and the loop continues with the next item.
Loop variables are in scope inside the catch block:
for (item in items) {
Process(item)
} catch {
e => log.warn(`Failed to process item ${item.id}`, e)
}
Use pattern matching with type annotations:
DoWork() catch {
e: TimeoutError => Retry()
e: NetworkError => FallbackResult()
// Other errors implicitly propagate to caller
}
Patterns are evaluated top-to-bottom. The first match wins. Unhandled errors propagate automatically.
Use pattern guards with if:
CallAPI() catch {
e: ApiError if e.status == 404 => null
e: ApiError if e.status >= 500 => Retry()
e: ApiError => DefaultResult()
// Non-ApiError errors propagate
}
The guard condition has access to the bound variable e.
You can match multiple types in a single pattern using |:
Fetch(url) catch {
e: TimeoutError | ConnectionError | DNSError => fallbackFetch(url)
e => null
}
Alternatively, you can define a type alias:
type NetworkIssue = TimeoutError | ConnectionError | DNSError
Fetch(url) catch {
e: NetworkIssue => fallbackFetch(url)
e => null
}
Or match each type separately:
Fetch(url) catch {
e: TimeoutError => fallbackFetch(url)
e: ConnectionError => fallbackFetch(url)
e: DNSError => fallbackFetch(url)
e => null
}
Or match against the union inline:
Fetch(url) catch {
e: TimeoutError | ConnectionError | DNSError => fallbackFetch(url)
e => null
}
| Category | Represents | Examples | Caught by untyped pattern? |
|---|---|---|---|
| Error | Recoverable failures | TimeoutError, NetworkError, custom types | Yes |
| Panic | Bugs / logic errors | IndexOutOfBounds, AssertionError | No |
Errors are expected failure modes your code should handle. Panics indicate bugs that should crash the program.
IndexOutOfBounds?IndexOutOfBounds is a Panic, not an Error. Untyped patterns like e only catch Errors:
function GetFirst(items: Item[]) -> Item {
return items[0] // Throws IndexOutOfBounds if empty
} catch {
e => DefaultItem() // Does NOT catch IndexOutOfBounds
}
If items is empty, IndexOutOfBounds propagates through the catch block and crashes the program.
Use checked accessors that return null instead of panicking:
| Unchecked (panics) | Checked (returns T | null) |
|---|---|
array[i] | array.get(i) |
map[key] | map.get(key) |
let first = items.get(0) // Returns null if empty, no panic
if (first != null) {
Process(first)
}
Match on a specific Panic type or the Panic union type.
Note that untyped patterns like e do not match Panics. If you want to log all failures (bugs and errors), you must handle Panics explicitly:
RunApp() catch {
// 1. Handle Bugs (Panics)
p: Panic => {
log.fatal("Bug encountered", p)
throw p
}
// 2. Handle Recoverable Errors
e => {
log.error("Request failed", e)
ErrorResponse()
}
}
Catching panics should be rare. It's usually better to fix the bug or use checked accessors.
Match on the specific type:
items[0] catch {
// Only catches index errors
p: IndexOutOfBounds => DefaultItem()
// Other panics (like AssertionError) still crash the program
}
If you only catch a specific Panic type, the compiler still adds an implicit handler for the remaining Panic types.
Panic is a union of these built-in types:
Collection Access
| Type | Thrown By | Cause |
|---|---|---|
IndexOutOfBounds | array[i] | Invalid index |
KeyNotFound | map[key] | Missing key |
Development Markers
| Type | Thrown By | Cause |
|---|---|---|
TodoError | todo() | Incomplete code executed |
UnreachableError | unreachable() | "Impossible" path reached |
AssertionError | assert() | Assertion failed |
PanicError | panic() | Generic fatal error |
Arithmetic
| Type | Thrown By | Cause |
|---|---|---|
DivisionByZero | a / b, a % b | Divisor is zero |
IntegerOverflow | a + b, a * b, etc. | Result exceeds bounds |
Runtime
| Type | Thrown By | Cause |
|---|---|---|
StackOverflow | Recursive calls | Recursion limit exceeded |
You can match on individual types or the full Panic union.
Yes. BAML has an open throw system. You can throw any value:
throw "Invalid state"
throw { code: 500, msg: "Error" }
These are treated as Errors (recoverable) and are caught by the _ wildcard. They are not Panics.
BAML provides built-in functions that throw Panics to mark bugs and incomplete code.
todo)function HandleRateLimit() -> Response {
todo("Implement rate limit handling")
}
todo() throws TodoError. Use it as a placeholder during development.
assert)function ValidateScore(score: float) -> float {
assert(score >= 0.0 && score <= 1.0, "Score must be in [0, 1]")
return score
}
assert() throws AssertionError if the condition is false.
unreachable, panic)function ProcessType(t: string) -> Result {
if (t == "a") {
return handleA()
} else if (t == "b") {
return handleB()
} else {
unreachable("Type must be 'a' or 'b'")
}
}
unreachable() throws UnreachableError. Use it for code paths that should never execute.
For general unrecoverable bugs, use panic():
panic("Something went very wrong")
Use a block expression with catch:
function Init() -> Server {
let config = LoadConfig()
let db = {
ConnectDB(config)
} catch {
e => ConnectReplica(config)
}
return Server(db)
}
The try keyword is optional but can clarify intent:
let db = try {
ConnectDB(config)
} catch {
e => ConnectReplica(config)
}
Both forms are semantically identical.
You can access variables from the scope surrounding the attached block:
You cannot access variables defined inside the try block:
{
let temp = Compute() // Defined inside
UseTemp(temp)
} catch {
e => log(temp) // Error: 'temp' is not accessible
}
Variables inside the block may be uninitialized when an error occurs, so they're not available in the handler.
BAML does not have a finally block. Place cleanup code after the catch expression:
let resource = Acquire()
let result = {
Use(resource)
} catch {
e => null
}
Release(resource) // Runs after success or caught error
catch affect my return type?The result type is the union of the try expression's type and each handler's return type:
// result is: Resume | null
let result = ExtractResume(text) catch {
e => null
}
// result is: int
let result = ComputeScore() catch {
e => 0 // Same type as success case
}
// result is: Data | DefaultData | null
let result = FetchData() catch {
e: NetworkError => DefaultData()
e => null
}
Handlers must return a value. A handler that only performs side effects is a compile error:
// ❌ Compile error: handler must return a value
let result = ExtractResume(text) catch {
e => log(e) // log() returns void, not Resume | null
}
// ✅ Log and return a fallback
let result = ExtractResume(text) catch {
e => {
log(e)
null
}
}
For functions, the catch return type must match the declared return type:
// ❌ Compile error: handler returns wrong type
function Extract(text: string) -> Resume {
client "gpt-4o"
prompt #"..."#
} catch {
e => null // Error: 'null' is not assignable to 'Resume'
}
// ✅ Widen return type to include fallback
function Extract(text: string) -> Resume | null {
client "gpt-4o"
prompt #"..."#
} catch {
e => null // OK: null is part of return type
}
The compiler adds implicit handlers to propagate unhandled errors and panics:
// You write:
DoWork() catch {
e: TimeoutError => null
}
// Compiler produces:
DoWork() catch {
e: TimeoutError => null
__implicit_panic: Panic => throw __implicit_panic // Re-throw all Panics
__implicit_error => throw __implicit_error // Re-throw unhandled Errors
}
If you add a catch-all pattern, only the panic handler is added:
// You write:
DoWork() catch {
e: TimeoutError => null
e => DefaultResult() // Catch-all for remaining errors
}
// Compiler produces:
DoWork() catch {
e: TimeoutError => null
__implicit_panic: Panic => throw __implicit_panic // Inserted before catch-all
e => DefaultResult()
}
If you explicitly handle Panic, no implicit panic handler is added:
// You write:
DoWork() catch {
p: Panic => handleBug(p)
e => null
}
// No implicit handlers added - you've handled everything explicitly
Untyped patterns (like e) do not match Panic types. To catch a Panic, you must annotate with a Panic type explicitly.