beps/docs/proposals/BEP-001-exceptions/legacy-ignore/ideas/catch-syntax-pattern-matching.md
Date: 2025-12-03
BAML uses pattern matching syntax inside catch blocks:
} catch {
e: TimeoutError => retry()
e: ParseError => null
_ => defaultValue()
}
This differs from the multi-catch syntax common in languages like Java, C#, and JavaScript:
// Multi-catch syntax (not used in BAML)
} catch (e: TimeoutError) {
retry()
} catch (e: ParseError) {
return null
} catch (e) {
return defaultValue()
}
This document examines the rationale and trade-offs.
BAML's match expression uses pattern matching:
let result = match (value) {
User { role: "admin" } => handleAdmin()
User { role: "user" } => handleUser()
_ => handleGuest()
}
Using the same syntax for catch blocks reduces the number of distinct syntactic forms developers need to learn.
Pattern matching provides a natural foundation for exhaustiveness checking:
} catch {
e: TimeoutError => retry()
e: ParseError => null
// Compiler can determine if all Error types are covered
}
This aligns with the safe keyword's requirement that all Error types be handled.
Pattern matching supports destructuring error objects:
} catch {
ApiError { code: 404, message } => handleNotFound(message)
ApiError { code: 500 } => handleServerError()
e: NetworkError => logAndRetry(e)
_ => defaultValue()
}
All error handlers are contained in one syntactic block:
} catch {
e: TimeoutError => retry()
e: ParseError => null
e: NetworkError => fallback()
_ => defaultValue()
}
The pattern matching structure accommodates implicit pattern insertion for the Panic/Error distinction:
// Source:
} catch {
e: TimeoutError => retry()
_ => defaultValue()
}
// Desugared:
} catch {
e: TimeoutError => retry()
_: Error => defaultValue()
_p: Panic => throw _p // implicit
}
Pattern matching syntax works naturally in expression contexts:
let result = GetData() catch {
e: TimeoutError => retry()
_ => null
}
} catch (e: TimeoutError) {
retry()
} catch (e: ParseError) {
return null
} catch (e) {
return defaultValue()
}
Advantages:
Disadvantages:
catch keywordmatch expression syntax// Pattern matching:
let result = GetData() catch {
e: TimeoutError => retry()
_ => null
}
// Multi-catch (unclear how this would work):
let result = GetData()
catch (e: TimeoutError) { retry() }
catch (e) { return null }
Allow both syntaxes:
// Pattern matching
} catch {
e: TimeoutError => retry()
_ => null
}
// Multi-catch
} catch (e: TimeoutError) {
retry()
} catch (e) {
return null
}
Trade-off: Provides flexibility but introduces two ways to accomplish the same task, leading to inconsistent codebases and increased tooling complexity.
} catch match {
e: TimeoutError => retry()
_ => null
}
Trade-off: More explicit but adds verbosity. The { ... } syntax already signals pattern matching.
} catch {
e: TimeoutError {
retry()
}
_ {
return defaultValue()
}
}
Trade-off: More similar to traditional switch statements but inconsistent with BAML's match expression, which uses arrow syntax.
BAML uses pattern matching syntax:
} catch {
pattern1 => handler1
pattern2 => handler2
_ => defaultHandler
}
This provides:
match expressionsThe pattern matching syntax in catch blocks is unfamiliar to developers coming from mainstream languages:
catch (e) with manual type checkingcatch (Type e) syntaxexcept Type as e: syntaxOnly Scala uses pattern matching in catch blocks. This means most developers will need to learn a new syntax for error handling, even if they're familiar with exceptions in other languages.
Question: Does the consistency with BAML's match expression outweigh the unfamiliarity for developers who don't know Scala?
Pattern matching in catch blocks combines two distinct concepts:
} catch {
ApiError { code: 404, message } => handleNotFound(message)
ApiError { code: 500 } => handleServerError()
e: NetworkError => logAndRetry(e)
_ => defaultValue()
}
Developers must understand:
Question: Is this cognitive overhead justified, or would a simpler syntax (even if more verbose) be easier to reason about?
When exhaustiveness checking fails, error messages must explain both pattern matching and exception handling:
} catch {
e: TimeoutError => retry()
// Error: Non-exhaustive catch block
// Missing patterns for: ParseError, NetworkError
// Or add wildcard pattern: _ => ...
}
Developers need to understand:
_ and e as wildcards_: Error is different from _: PanicQuestion: Will error messages be clear enough for developers unfamiliar with pattern matching?
Pattern matching has first-match semantics, which can be surprising:
} catch {
_ => defaultValue() // Catches everything
e: TimeoutError => retry() // Never reached!
}
This is different from some multi-catch implementations where specificity matters. Developers must understand that order matters.
Question: Will developers expect specificity-based matching instead of first-match?
Pattern matching syntax can make certain control flow patterns less obvious:
} catch {
e: TimeoutError => {
if (retryCount < 3) {
return retry()
} else {
return null
}
}
_ => null
}
Multi-statement handlers require block syntax, which can become verbose. The arrow syntax suggests expression-oriented code, but handlers often need multiple statements.
Question: Does the arrow syntax create false expectations about handler complexity?
With trailing catch syntax, it's not immediately clear what scope the catch covers:
function Process() -> Result {
let x = GetData()
let y = Transform(x)
return y
} catch {
_ => null
}
Does the catch cover:
return y statement?(Answer: the entire function body, but this may not be obvious)
Question: Does the trailing position make the scope clear enough, or should there be a more explicit marker?
While destructuring is powerful, it can make catch blocks harder to read:
} catch {
ApiError { code: 404, message, details: { retryAfter } } => {
log.warn(message)
scheduleRetry(retryAfter)
return null
}
ApiError { code, message } => handleError(code, message)
_ => defaultValue()
}
Question: Does the expressiveness of destructuring justify the added complexity in error handling code?
Developers must learn:
This is a steeper learning curve than traditional multi-catch.
Question: Is the learning investment worth the benefits, especially for developers who only occasionally write BAML code?
Multi-catch syntax could potentially support the same features with extensions:
// Hypothetical: multi-catch with destructuring
} catch (ApiError { code: 404, message }) {
handleNotFound(message)
} catch (e: TimeoutError) {
retry()
} catch (e) {
defaultValue()
}
Question: Could we achieve the same goals (destructuring, exhaustiveness) with a more familiar syntax?
Scala uses pattern matching in catch:
try {
riskyOperation()
} catch {
case e: TimeoutError => retry()
case e: ParseError => null
case _ => defaultValue()
}
BAML's syntax is similar but uses => instead of case.
Swift uses multi-catch:
do {
try riskyOperation()
} catch let error as TimeoutError {
retry()
} catch {
return defaultValue()
}
Rust doesn't have exceptions but uses match for Result types:
match result {
Ok(value) => handle_value(value),
Err(e) => handle_error(e),
}
Single catch with manual type checking:
try {
riskyOperation()
} catch (e) {
if (e instanceof TimeoutError) {
retry()
} else {
return defaultValue()
}
}
BAML's design philosophy is to follow TypeScript conventions unless there is a substantial benefit to users that justifies the learning cost of a different syntax.
TypeScript's exception handling is:
try {
riskyOperation()
} catch (e) {
if (e instanceof TimeoutError) {
retry()
} else {
return null
}
}
This is familiar, well-understood, and requires no new syntax to learn.
The question: Does pattern matching in catch blocks provide enough value to justify deviating from this familiar pattern?
In AI engineering, error handling is not exceptional—it's routine. LLMs fail frequently and predictably. The safe keyword requires exhaustive error handling.
Pattern matching provides compile-time guarantees about exhaustiveness:
// Compiler error: missing NetworkError
} catch {
e: TimeoutError => retry()
e: ParseError => null
}
TypeScript's try/catch cannot provide this guarantee. Developers must manually ensure all error types are handled.
Value: Prevents bugs from unhandled error types at compile time, not runtime.
BAML's trailing catch syntax is designed to be additive—you append error handling without restructuring code:
function Extract() -> Resume | null {
client "gpt-4o"
prompt #"..."#
} catch {
_ => null
}
This is fundamentally expression-oriented. The catch block is part of the function's value, not a separate statement.
Multi-catch syntax doesn't naturally support this:
// How would this work?
function Extract() -> Resume | null {
client "gpt-4o"
prompt #"..."#
} catch (e: TimeoutError) {
return null
} catch (e) {
return null
}
The return statements are awkward—they're inside the catch clauses but conceptually part of the function's return value.
Value: Pattern matching syntax aligns with expression-oriented error handling.
Inline catch is a core feature:
let user = GetUser(id) catch { _ => null }
Multi-catch would be verbose:
let user = GetUser(id) catch (e) { return null }
And with multiple handlers:
// Pattern matching:
let data = Fetch() catch {
e: Timeout => retry()
_ => null
}
// Multi-catch:
let data = Fetch() catch (e: Timeout) { return retry() } catch (e) { return null }
Value: Conciseness matters for inline error handling.
The Panic/Error distinction requires implicit pattern insertion:
} catch {
e: TimeoutError => retry()
_ => null
}
// Desugars to insert: _p: Panic => throw _p
With multi-catch, where does the implicit handler go?
} catch (e: TimeoutError) {
retry()
} catch (e) {
return null
}
// Insert implicit panic handler... where?
Pattern matching provides a clear insertion point (after user patterns, before the implicit panic re-throw).
Value: Clean semantics for the Panic/Error distinction.
BAML already has match expressions with pattern matching. Adding pattern matching to catch means learning one pattern matching syntax that works in two places, not two different syntaxes.
Value: Reduced cognitive load overall (one pattern matching syntax vs. two different error handling syntaxes).
Most developers don't know Scala. They will need to learn pattern matching syntax specifically for error handling.
Counter: BAML already requires learning pattern matching for match expressions. The marginal cost is lower than it appears.
Combining exception handling with pattern matching increases cognitive load.
Counter: The alternative (TypeScript-style manual type checking) also has cognitive load—it's just different. Pattern matching makes the compiler do the work.
Pattern matching has more concepts to learn than simple try/catch.
Counter: The learning investment pays off through compile-time safety and more expressive error handling.
Pattern matching in catch blocks does justify the deviation from TypeScript syntax, but only because of the combination of factors:
safe functionsNo single factor alone would justify the deviation. But together, they create a compounding benefit that outweighs the learning cost.
Important caveat: Pattern matching syntax can desugar to chained catch clauses at the implementation level. This means most of the technical capabilities (exhaustiveness checking, Panic/Error distinction, etc.) could theoretically be achieved with either syntax.
The real question is: Which surface syntax provides better developer ergonomics?
We would reconsider pattern matching if:
The technical capabilities are achievable with either syntax through desugaring. The choice is about which syntax better serves developers writing and reading BAML code.
Use pattern matching syntax in catch blocks. The deviation from TypeScript is justified by the combination of:
match expressionsThe learning cost is real, but the benefits compound in ways that TypeScript-style syntax cannot match.