beps/docs/proposals/BEP-001-exceptions/legacy-ignore/ideas/panic-vs-error.md
Date: 2025-12-03
Drawing inspiration from Rust, we want to distinguish between two fundamentally different classes of exceptions:
Languages like Rust provide special constructs for the latter category:
panic!() - Something went terribly wrongtodo!() - Mark incomplete implementationsunreachable!() - Document impossible code pathsassert!() - Validate invariantsThese serve different purposes than regular error handling:
Since BAML supports union subtyping (A is a subtype of A | B), we can model this distinction using union types:
// Individual exception types (concrete classes)
TimeoutError
ParseError
NetworkError
IndexOutOfBoundsError
TodoError
AssertionError
UnreachableError
// Union type aliases
type Error = TimeoutError | ParseError | NetworkError | ...
type Panic = IndexOutOfBoundsError | TodoError | AssertionError | UnreachableError | ...
type Exception = Error | Panic
Key principle: Panic represents bugs. Error represents expected failures.
The critical design decision: Wildcards in catch blocks have type Error, not Exception.
This means you cannot accidentally catch panics—you must be explicit.
function Process(items: Item[]) -> Result {
let first = items[0] // Can throw IndexOutOfBoundsError (a Panic)
return TransformItem(first)
} catch {
e: TimeoutError => retry()
_ => DefaultResult() // Wildcard matches only Error, not Panic
}
// Desugars to:
} catch {
e: TimeoutError => retry()
_: Error => DefaultResult()
/* implicit */
_p: Panic => throw _p
}
If items is empty, the IndexOutOfBoundsError propagates up—it's not caught by the wildcard.
function DefensiveProcess(items: Item[]) -> Result {
let first = items[0]
return TransformItem(first)
} catch {
p: Panic => {
log.fatal("Panic occurred", p)
throw p // Or handle it
}
e: Error => DefaultResult()
}
To catch panics, you must explicitly pattern match on Panic (or specific panic types).
To catch both errors and panics, you need two patterns:
} catch {
p: Panic => handlePanic(p)
e: Error => handleError(e)
}
You cannot write:
} catch {
everything: Exception => handle(everything) // ❌ Not allowed
}
This forces developers to think about panics separately from errors.
assert(condition: bool, message: string)Validates runtime invariants. Throws AssertionError (a Panic) if the condition is false.
function ValidateScore(score: float) -> float {
assert(score >= 0.0 && score <= 1.0, "Score must be in [0, 1]")
return score
}
Use case: Validating LLM outputs against known constraints.
todo(message: string) -> TMarks incomplete implementations. Throws TodoError (a Panic).
function ExtractResume(text: string) -> Resume | null {
client "gpt-4o"
prompt #"Extract resume from {{ text }}"#
} catch {
e: RateLimitError => todo("Implement retry logic with exponential backoff")
e: TimeoutError => null
}
Use case: Prototype to production workflow—mark areas to revisit.
unreachable(message: string) -> TDocuments code paths that should be impossible. Throws UnreachableError (a Panic).
function ProcessUser(user_type: string) -> Result {
if (user_type == "admin") {
return AdminResult()
} else if (user_type == "user") {
return UserResult()
} else {
unreachable("user_type must be 'admin' or 'user' (validated upstream)")
}
}
Use case: Document assumptions about control flow.
To avoid panics, provide safe alternatives that return optionals:
// Unsafe (panics on out of bounds)
let first = items[0] // Throws IndexOutOfBoundsError
// Safe (returns optional)
let first = items.get(0) // Returns Item | null
let first = items.first() // Returns Item | null
// With error handling
let first = items.get(0) catch { _ => DefaultItem() }
Design principle:
array[i] is for when you know i is in bounds (assertion of invariant)array.get(i) is for when it might be out of bounds (defensive programming)The type checker does not require exhaustiveness over Panic types.
// ✅ Valid: You don't need to handle TodoError, AssertionError, etc.
} catch {
e: TimeoutError => null
}
The implicit desugaring adds the panic re-throw, so panics always propagate unless explicitly caught.
todo(), assert(), unreachable() help during prototypingPanic in production code?todo() be a compile error in production builds?TodoError) be uncatchable in production?array[i] vs array.get(i) distinction in error messages?This proposal has important implications for the safe keyword from safe-unsafe-coloring.md.
A safe function or expression guarantees that it handles all Error types, but not Panic types.
Rationale: Panics represent bugs (programmer mistakes), not expected runtime failures. A "safe" function means "won't throw expected errors," but bugs can still surface as panics.
When using the safe keyword at a call site, wildcards only need to cover Error types:
// ✅ Valid: safe with wildcard catches all Errors
let x = safe GetData() catch { _ => null }
// Panics still propagate (by design)
// If GetData() throws IndexOutOfBoundsError, it escapes
Desugaring:
let x = safe GetData() catch { _ => null }
// Becomes:
let x = GetData() catch {
_: Error => null
/* implicit */
_p: Panic => throw _p
}
The safe keyword ensures all Error types are handled, but panics are allowed to propagate.
A function declared as safe must handle all Error types, but can throw Panic:
// ✅ Valid: safe function can panic
safe function Process(items: Item[]) -> Result {
let first = items[0] // Can throw IndexOutOfBoundsError (Panic)
return TransformItem(first)
} catch {
e: TimeoutError => retry()
_ => DefaultResult() // Handles all Errors
}
// Panics are allowed to escape
If you want a function that truly cannot throw anything (including panics), you must explicitly handle them:
// Truly panic-proof function
safe function DefensiveProcess(items: Item[]) -> Result {
let first = items.get(0) catch { _ => null } // Use safe accessor
if (first == null) {
return DefaultResult()
}
return TransformItem(first)
} catch {
e: Error => DefaultResult()
}
// No panics possible - uses safe accessors
The type checker's exhaustiveness checking for safe expressions only considers Error types:
// ✅ Valid: wildcard is exhaustive over Error
let x = safe GetData() catch { _ => null }
// ✅ Valid: explicit Error patterns are exhaustive
let x = safe GetData() catch {
e: TimeoutError => retry()
e: ParseError => null
e: NetworkError => null
_ => null // Catches remaining Errors
}
// ❌ Compile error: not exhaustive over Error
let x = safe GetData() catch {
e: TimeoutError => null
// Missing other Error types and no wildcard
}
To catch panics in a safe expression, you must be explicit:
// Catch both Errors and Panics
let x = safe GetData() catch {
p: Panic => {
log.fatal("Unexpected panic", p)
throw p // Or handle it
}
e: Error => null
}
A function is inferred as safe if:
catch blocks that handle all Error typesPanics do not affect safe inference:
// Inferred as safe (no unsafe operations)
function FormatName(first: string, last: string) -> string {
return first + " " + last
}
// Inferred as unsafe (calls LLM, no catch)
function Extract(text: string) -> Resume {
client "gpt-4o"
prompt #"..."#
}
// Inferred as safe (all Errors handled)
function SafeExtract(text: string) -> Resume | null {
client "gpt-4o"
prompt #"..."#
} catch {
_ => null // Handles all Errors
}
// Inferred as safe (even though it can panic)
function ProcessFirst(items: Item[]) -> Item {
return items[0] // Can panic, but no Errors
}
This design aligns with BAML's "prototype to production" philosophy:
array[0], assert(), todo() freely. Panics help you catch bugs early.safe to functions to ensure all Error types are handled.array.get(0)) where appropriate.The key insight: safe means "handles expected failures," not "cannot fail under any circumstances." Bugs (panics) are a separate concern from error handling.