beps/docs/proposals/BEP-001-exceptions/legacy-ignore/ideas/discussion-2025-12-03.md
Date: 2025-12-03
We are revisiting the syntax for error handling in BAML. User feedback rejected:
function = try { ... } (Breaking change).function ... try { ... } (Syntactically weird).We need a model that:
try/catch exists).Instead of thinking of try as a control flow structure, let's think of catch as an operator on blocks.
catch can be attached to ANY block.
Function Block:
function Extract(text) {
client "gpt4"
prompt #"..."#
} catch {
_ => null
}
Result: The catch handles errors from the function body. No extra indentation.
Imperative Block:
let result = {
let c = Client.new()
c.run()
} catch {
_ => null
}
Result: result gets the value of the block or the catch.
Try Block (Syntactic Sugar):
let result = try {
// ...
} catch {
_ => null
}
Theory: try { ... } is identical to { ... }, but it signals intent to the reader.
"I shouldn't have to learn two ways": You don't. You learn one way: "Attach catch to the thing that might fail."
"Mixing declarative and imperative is confusing":
try block inside your declarative function. You can just attach catch to the outside.try { ... } (or just { ... }), and it works the same way."Familiarity":
try as a valid keyword for imperative code where it feels natural.| Context | Syntax | "Implicit" or "Explicit"? |
|---|---|---|
| Function Level | function F() { ... } catch { ... } | Implicit Try (Scope = Function Body) |
| Statement Level | let x = try { ... } catch { ... } | Explicit Try (Scope = Block) |
| Expression Level | let x = { ... } catch { ... } | Implicit Try (Scope = Block) |
Key Insight: try is just a "loud" block opener. It's optional semantically but helpful for readability in imperative code.
The user found try { client ... } confusing.
With Universal Catch, you avoid this by defaulting to Function-Level Catch for LLM functions.
// ✅ Natural: Catch is part of the function definition
function Extract(text) {
client "gpt4"
prompt #"..."#
} catch {
_ => null
}
But if you have complex logic inside an imperative function:
// ✅ Natural: Explicit try for a dangerous subsection
function ComplexLogic() {
let safe_part = ...
let risky_part = try {
CallLLM()
} catch {
_ => null
}
return safe_part + risky_part
}
This seems to satisfy all constraints:
function try.try is just an optional marker for a block) feel consistent to you?In AI Engineering, failure is normal, not exceptional. Code often evolves from a "Happy Path" prototype to a "Resilient" production system.
The Pain Point: In traditional languages, adding error handling to a function requires a Structural Refactor.
try { ... } forces re-indenting the entire body.try block are scoped to it. To use them later, you must hoist declarations outside.Result<T> breaks all callers.Goal: BAML seeks Additive Resilience. You should be able to "snap on" error handling without rewriting the happy path.
try/catch StatementOriginal Code:
function Extract(text) {
let client = Client.new();
return client.run(text);
}
Syntax Update (The "Refactoring Tax"):
function Extract(text) {
// 1. Hoisting Tax: Must declare variable outside
let client: Client | null = null;
// 2. Indentation Tax: Everything moves right
try {
client = Client.new();
} catch {
return null;
}
// 3. Safety Tax: Must assert or check for null
if (client == null) {
// What do we do here? We already caught the error?
// This flow is confusing.
return null;
}
return client.run(text);
}
Rejected Because:
| null types and assertions.client definitions in an imperative try block feels semantically wrong.Result<T, E>)function Extract(text) -> Result<Resume, Error> { ... }
Rejected Because:
let x = try { ... })The Good (Imperative Code): It solves the hoisting problem beautifully for imperative code.
function FetchData() -> Data | null {
// ✅ Clean: No hoisting, 'data' is assigned the result
let data = try {
let c = Client.new()
c.fetch()
} catch {
_ => null
}
return data
}
The Bad (Declarative Code): It falls apart when wrapping declarative configurations.
function Extract(text) -> Resume | null {
// ❌ Confusing: "Try to define a client?"
// The client definition is static configuration, not an operation to "try".
let result = try {
client "openai/gpt-4o"
prompt #"..."#
} catch {
_ => null
}
return result
}
Status: Accepted as part of "Universal Catch", but Rejected as the only way because:
function ... try)// The return type makes the 'try' look stranded
function Extract(text) -> Resume | null try {
client "..."
} catch { ... }
Rejected Because:
try keyword appears after the return type but before the body.try usually starts a block, it doesn't modify a function declaration.try function ...)try function Extract(text) -> Resume | null {
client "..."
} catch { ... }
Rejected Because:
try is a verb, function is a noun/keyword. try function reads like "attempt to define a function", not "define a function that attempts something".let x = ... catch ...)let client = Client.new() catch { _ => null }
Status: Accepted (as "Inline Catch"), but insufficient on its own.
function = try { ... }function Extract(text) -> Resume | null = try { ... }
Rejected Because:
Force users to wrap declarative functions in a separate imperative function to handle errors.
// 1. Define the unsafe declarative function
function ExtractUnsafe(text) -> Resume {
client "gpt4"
prompt #"..."#
}
// 2. Define a safe wrapper
function Extract(text) -> Resume | null {
try {
return ExtractUnsafe(text)
} catch {
return null
}
}
Rejected Because:
We selected Universal Catch because it offers the best compromise:
function F() { ... } catch { ... } allows adding resilience without touching the body.try { ... } is supported as syntactic sugar for imperative blocks.catch attaches to any block (function, if, for, or anonymous).