beps/docs/proposals/BEP-001-exceptions/legacy-ignore/ideas/scoped-catch-syntax.md
This proposal introduces a scoped catch syntax that acts as a declarative error handler for the current scope.
The core idea is simple: A catch block implicitly wraps the remainder of its scope.
The catch block must be the first statement in the scope. It cannot be preceded by other statements (like variable declarations) in the same block.
You can view this syntax as syntactic sugar for a traditional try-catch block where the try automatically extends from the catch block to the end of the scope.
What you write (BAML):
function Foo(arg: string) {
// Catch must be the first statement
catch {
e: Err => { return arg } // Can access parameters or outer variables
}
// Everything below is effectively inside a 'try'
let x = 1;
risky_operation_1()
}
How to think about it (Desugaring):
function Foo(arg) {
try {
let x = 1;
risky_operation_1()
} catch (e) {
if (e matches Err) return arg;
throw e;
}
}
This syntax is designed to support the lifecycle of AI engineering: moving from fragile prototypes to resilient production systems without rewriting code.
Prototype (Happy Path): You write linear code to test your prompts.
function Extract() {
client "openai"
prompt #"..."#
}
Production (Resilient): To handle timeouts or refusals, you don't need to wrap/indent your logic or change call sites. You simply add a catch block at the top.
function Extract() {
catch { ... } // <--- Just added this
// Original code remains untouched
client "openai"
prompt #"..."#
}
This makes error handling an additive layer rather than a structural refactor.
// Data definitions
class Resume {
name string
experience string[]
}
// 1. Function-level Catch (Declarative LLM Function)
function ExtractResume(text: string) -> Resume {
// Catch block handles LLM failures, Parsing errors, etc.
// Must be the first statement in the scope
catch {
// Return a default/fallback value on failure
e: LlmError => {
return Resume { name: "Unknown", experience: [] }
}
}
// Specifies the LLM client
client "openai/gpt-4o"
// The actual prompt (BAML handles the execution and parsing)
prompt #"
Extract the resume details from the text below:
{{ text }}
"#
}
// BAML doesn't have a built-in Result type, so we define one
class Success {
value Resume
}
class Failure {
error string
}
type Result = Success | Failure
// 2. Scope-level Catch (Imperative Logic)
function ProcessBatch(items: string[]) -> Result[] {
let results = []
for (item in items) {
catch {
// Capture item-specific errors without failing the batch
other => {
results.append(Failure { error: other.message })
continue
}
}
// Call the declarative function
let processed = ExtractResume(item)
results.append(Success { value: processed })
}
return results
}
A key feature is the ability to distinguish between returning a value for the block (assignment) and returning from the function.
function GetPrice(itemId: string) -> float {
let price = {
catch {
// ✅ Expression return: Returns 0.0 to 'price' variable (fallback)
_: ApiError => { 0.0 }
// ✅ Function return: Exits the entire function immediately
_: AuthError => { return -1.0 }
}
externalApi.getPrice(itemId)
}
return price * 1.2 // tax applied to price (0.0 or actual)
}
Minimal Diff Overhead: When making code error-prone, no need for:
try keyword at the beginning or at every call site (unlike Swift/Rust)catch block at the endScoped Variable Access: The catch block has access to all parameters and variables declared above it in the scope, making context more explicit and accessible
Declarative Error Handling: By placing error handling at the top of a scope, it acts as a declaration of "what can go wrong" rather than wrapping code
Building AI agents (code that orchestrates LLMs) typically follows a distinct lifecycle:
In traditional languages, this transition is painful. Adding error handling often requires:
try/catch (indentation changes).With Scoped Catch, "productionizing" an agent function is strictly additive: you simply paste a catch block at the top of the scope. The original prototyping logic remains untouched, unindented, and linear. This lowers the activation energy for adding reliability, ensuring that "quick prototypes" can actually evolve into robust production systems without a rewrite.
deferdefer, this appears at scope entry but executes under special conditionsdefer always executes on scope exit; this only executes on errorsfunc processFile() {
let file = openFile()
defer { file.close() } // Appears early, executes on exit
// ... code
}
result, err := doSomething()
if err != nil {
// handle immediately
}
? Operator with Match? at call sites; this auto-infersmatch operation() {
Ok(value) => { /* success */ },
Err(MyError) => { /* handle */ },
Err(e) => { /* other errors */ }
}
try, catch appears at scope endtry {
// indented code
} catch (MyError e) {
// handler at end
}
if err != nil { return err }
with)with handles setup/teardown, not error handlingwith open('file') as f:
# code
# cleanup happens automatically
A distinguishing feature of this syntax is the named wildcard pattern for error propagation:
function Foo(param: T) -> Bar {
catch {
_: MyError => { return Bar.default() }
_: DatabaseError => { return Bar.fromCache() }
// Named wildcard captures all other errors
other => {
log.error("Unexpected error in Foo", other)
throws other
}
}
// ... code
}
Critical Implementation Detail: The wildcard pattern is implicitly added to every catch block via compiler desugaring.
What you write:
function Foo() -> Bar {
catch {
_: MyError => { return Bar.default() }
}
// code
}
What the compiler generates:
function Foo() -> Bar {
catch {
_: MyError => { return Bar.default() }
__implicit_other__ => { throws __implicit_other__ } // Implicitly added
}
// code
}
Benefits of Implicit Desugaring:
other => { throws other } everywhereeval feature) gracefullyWhen to Write Explicit Wildcards:
// Explicit wildcard for logging before propagation
catch {
_: KnownError => { return fallback() }
other => {
log.error("Unexpected error", other)
metrics.increment("unknown_errors")
throws other
}
}
Benefits:
other) provides access to the error instanceeval)Comparison to Other Languages:
Err(e) => return Err(e) in match expressionstrycatch (Exception e) which absorbs errorsif err != nil { return err }Question: Does the catch block apply to the entire scope below it, or only to specific statements?
Decision: Applies to entire scope after the catch block
Rationale: Simplifies reasoning about error handling boundaries - the catch applies to everything below it in the same scope
Question: How are error types inferred?
Decision: Analyze all function calls in every scope to determine which errors can be thrown
Rationale:
Implementation Note: Requires sophisticated static analysis to track error propagation through the call graph
Question: What happens if an error type is not caught?
Decision: Implicitly propagated via automatic wildcard desugaring
How It Works: The compiler automatically adds an implicit wildcard to every catch block that propagates unhandled errors. Developers only need to write explicit wildcards when they want to inspect/log errors before re-throwing.
Example:
// What you write
function Foo() -> Bar {
catch {
_: MyError => { return Bar.default() }
// No explicit wildcard needed
}
// code that might throw MyError and OtherError
}
// What the compiler generates (desugared)
function Foo() -> Bar {
catch {
_: MyError => { return Bar.default() }
__implicit__ => { throws __implicit__ } // Auto-added by compiler
}
// code
}
// When you want to log/inspect unhandled errors
function Bar() -> Baz {
catch {
_: KnownError => { return fallback() }
other => { // Explicit wildcard overrides implicit one
log.error("Unexpected error", other)
throws other
}
}
}
Rationale:
eval gracefullyQuestion: How do catches interact when scopes are nested?
Decision: Inner catches trigger first and can re-throw to outer scopes
Example:
function Foo() -> Bar {
catch {
_: MyError => { return Bar.default() } // Outer catch
other => { throws other }
}
if (condition) {
catch {
_: MyError => { return Bar.new(...) } // Inner catch handles first
other => { throws other } // Re-throws to outer catch
}
// MyError thrown here goes to inner catch first
}
}
Rationale:
return to provide a value or throws to propagateQuestion: How do you propagate errors to callers?
Decision: Use explicit throws keyword with named wildcards
Example:
function Foo() -> Bar {
catch {
_: MyError => { return Bar.default() }
// Named wildcard captures unhandled errors
other => { throws other }
}
// code that might throw various errors
}
Rationale:
other => { throws other }) makes propagation explicitQuestion: What variables can the catch block access and modify?
Decision: Catch blocks can access all variables declared in outer scopes (and function parameters).
Example:
function Foo(param: T) -> Bar {
let x = 10
// Create a new scope to capture 'x'
{
catch {
// ✅ Can access param (function parameter)
// ✅ Can access x (declared in outer scope)
return Bar.new(param, x)
}
// Code that uses x and might throw
risky_op(x)
}
}
Rationale:
Question: How does the catch block provide return values?
Decision: Catch handlers can use return to provide the function's return type, or throws to propagate
Example:
function Foo() -> Bar {
catch {
_: MyError => { return Bar.default() } // Provide return value
_: OtherError => { throws OtherError() } // Re-throw
other => { throws other } // Propagate unhandled errors
}
// code that returns Bar
}
Rationale:
Question: Does the order of error handlers in the catch block matter?
Example:
catch {
_: SpecificError => { .. }
_: GeneralError => { .. } // Would this catch SpecificError if it extends GeneralError?
}
Options:
Question: Should there be a way to catch "any error"?
Decision: Support named wildcards for catching unhandled errors
Example:
catch {
_: MyError => { return Bar.default() }
other => {
// 'other' is a named wildcard that captures any unhandled error
log(other)
throws other
}
}
Rationale:
other) provide access to the error instanceQuestion: How do you access the error instance/data in the handler?
Decision: Use pattern matching syntax (like Rust)
Examples:
catch {
_: MyError => { .. } // No access to error instance
e: MyError => { .. } // Access via parameter binding
MyError { code, msg } => { .. } // Destructure error fields
}
Rationale:
Question: How does this work with async functions?
Decision: Top-level main function is responsible for catching all exceptions and returning a safe value. Runtime can provide implicit exception handler.
Example:
async function Foo() -> Bar {
catch {
_: NetworkError => { return Bar.default() }
}
await someAsyncCall() // Can be caught by the catch block
}
// Top-level main function catches all uncaught exceptions
function main() {
catch {
other => {
print(other)
return 1 // Safe exit code
}
}
// ... application logic
}
Rationale:
main acts as final safety net for uncaught exceptionsImplementation Details:
Question: What compile-time guarantees are provided?
Decision: Default to optional handling with implicit propagation, but support a strict mode for exhaustive checking of known errors.
Context:
Since every catch block includes an implicit forwarder (__implicit__ => { throws __implicit__ }), strict exhaustiveness is not required for runtime safety—unhandled errors simply propagate. However, developers often want to ensure they haven't accidentally omitted a known error case.
Syntax Addition: catch(strict)
Example:
function Foo() -> Bar {
// strict: Compiler ERROR if 'MyError' is reachable but not handled below
catch(strict) {
_: MyError => { return Bar.default() }
// Implicit forwarder is STILL added for unknown/future errors
}
// code that throws MyError
}
Rationale:
catch(strict) enforces that all known error types reachable in the scope are explicitly handled.Question: Should error types be explicitly declared in function signatures (e.g. throws [A, B])?
Decision: No. Rely entirely on inference.
Rationale:
Question: Can catch blocks appear anywhere in a scope, or multiple times?
Decision: Strictly one catch block per scope, located only at the top.
Example:
function Foo() -> int {
// ✅ Valid: Top of function scope
catch {
_: MyError => { return 0 }
}
// ❌ Invalid: Catch in middle of scope
// catch { ... }
if (condition) {
// ✅ Valid: Top of inner scope
catch {
_: MyError => { return 1 } // Returns from the block (which returns from function)
}
// ❌ Invalid: Multiple catches
// catch { ... }
throw MyError()
}
// Block return example
let x = {
// ✅ Valid: Top of block scope
catch {
_: MyError => { return 10 } // Returns 10 from the FUNCTION
_: MyError2 => { 10 } // Returns 10 from the BLOCK (binds x = 10)
}
fallible_op() // returns int
}
}
Rationale:
The scoped-catch syntax offers a novel approach to error handling that prioritizes minimal diff overhead and clear variable scoping. Its main innovation is placing error handlers at the top of scopes rather than wrapping code. This is particularly valuable for AI-assisted coding where small changes shouldn't create large diffs.
However, this approach introduces unusual control flow and requires resolution of significant design decisions around error inference, propagation, and scope semantics. The success of this proposal depends on carefully balancing convenience with type safety and predictability.