RFC-0049 — Exception philosophy

  • Status: Accepted — 2026-06-14
  • Author: Mark Truluck mark.truluck@cogiton.com
  • Builds on: RFC-0043 (async layered casing / E703), RFC-0044 (context-stack cleanup), #86 (the user-facing “Exception Policy” section in docs/frame_language.md)
  • First applications: #86 (C++ core dispatch → RAII), #87 (full -fno-exceptions persist), #88 (async exception dependency)

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.

Summary

Frame transpiles one source to seventeen target languages whose error-handling mechanisms differ fundamentally: some throw exceptions (Java, C#, C++, Python, …), some return error values (Rust Result, Go error), some have nothing (C). A recurring question — “may generated code use exceptions, and where?” — keeps surfacing per feature (async E703, persist, the context stack). This RFC gives the durable answer as two orthogonal axes, so the per-feature decisions stop being ad hoc:

  1. Exceptional vs. Expected. Generated code MAY use a language’s error mechanism to signal genuine errors and broken contracts. It MUST NOT use any error mechanism — least of all exceptions — for expected control flow, most commonly type discovery. The latter is a query, and queries never throw.
  2. One semantic, per-target mechanism, with a fallback. Frame expresses a portable semantic (“this error is signaled”). Each backend emits it in its native mechanism. Where that mechanism is a thrown exception and the target can compile exceptions out (notably C++ -fno-exceptions, required by Godot web), the generated code MUST provide a fallback so the output stays compilable and well-defined without an exception runtime.

This is the rationale behind the user-facing Exception Policy summary in the language reference; that section is the “what,” this RFC is the “why.”

Motivation

“Exceptions aren’t evil, but they’re easy to use improperly.” The improper uses are well known and language-agnostic:

  • Exceptions as control flow. try { x = cast<int>(v); } catch { try cast<double>… } uses a thrown-and-caught exception to discover a type. Nothing has failed — the value is some valid type — so this is branching, not error handling. Every mature serializer rejects it (see Prior art).
  • Exceptions with no fallback in a transpiler. A generated throw that the target may compile out (C++ -fno-exceptions) makes the output uncompilable or undefined there, even though the semantic (signal an error) has a perfectly good non-exception expression (abort, an error return, a Result).

Both are silent: the first compiles and “works” while abusing the runtime; the second compiles fine until a downstream consumer turns exceptions off. Frame’s multi-target nature makes the second especially sharp — it routinely emits the same semantic into a language that has no exceptions at all.

Prior art

The dividing line this RFC draws is the one real serialization frameworks already draw:

Framework Errors (corrupt blob, type mismatch, missing key) Expected queries (which type? key present?)
nlohmann::json (C++) parse()/.get<T>()/.at() throw .contains(), .is_number(), .value(k,def), non-throwing find(), pointer any_cast<T>(&)no throw
Jackson / ObjectInputStream (Java) throw IOException / InvalidClassException reflective has/is queries — no throw
System.Text.Json (C#) throw JsonException TryGetProperty, ValueKind — no throw
pickle / json (Python) raise UnpicklingError / JSONDecodeError in, isinstance — no raise
serde (Rust) from_str() -> Result<T, Error>values, no exceptions pattern match / is_* — no panic
encoding/json (Go) Unmarshal() -> errorvalues, no exceptions type switch — no panic

Two conclusions: (a) errors are signaled — by exceptions where the language uses them, by error values where it doesn’t; (b) no framework, throwing or not, uses its error channel for routine type discovery. Frame adopts both.

The policy

R1 — No error mechanism for expected control flow (MUST NOT)

Generated code MUST NOT use exceptions (or Result/error-returns/panics) to express expected branching, including type discovery. Use the language’s non-throwing query: the pointer form std::any_cast<T>(&v) (returns null), JSON is_number() / contains(), a stored type tag, or a discriminated union.

R2 — Errors and broken contracts MAY be signaled idiomatically (MAY)

Generated code MAY signal a genuine error or precondition violation using the target’s idiomatic error mechanism — throw on exception-based targets, Result on Rust, error return on Go, return code / abort on C.

R3 — Throws MUST have a no-exceptions fallback (MUST)

Where R2’s mechanism is a thrown exception and the target supports compiling exceptions out, the generated code MUST also emit a fallback that keeps the output compilable and well-defined without the exception runtime. The fallback is selected at the target’s compile time (e.g. #if defined(__cpp_exceptions) in C++), so exception-enabled builds are unchanged:

#if defined(__cpp_exceptions) || defined(__EXCEPTIONS)
    throw std::runtime_error("E700: system not quiescent");
#else
    std::fprintf(stderr, "E700: system not quiescent\n");
    std::abort();
#endif

R4 — Invariants are maintained without exceptions (MUST)

Core runtime invariants (e.g. the kernel’s context-stack balance) MUST be maintained with each language’s scope-cleanup idiom (RAII / defer / finally), never a mandatory catch-and-rethrow. See #86 / RFC-0044. This keeps core dispatch exception-free on every backend independent of any feature.

Precondition errors vs. data errors

A useful refinement for choosing the fallback. A data error (corrupt blob, version skew) is recoverable in principle → Result/throw/error-return that a caller can handle. A precondition violation (the program called an operation in an illegal state — e.g. save() mid-dispatch, E700) is a programmer bug → fail-fast (assert/abort/panic) is the canonical handling; the softer throw is kept only where it is the existing, catchable behavior. R3’s abort fallback is therefore not a downgrade for preconditions — for E700/E703 it is arguably the more correct handling, and the throw is the compatibility path.

Classification of Frame’s exception uses

Use Kind Rule Treatment Status
Context-stack balance (core dispatch) Invariant R4 RAII / defer / finally; never catch-rethrow #86 done (C++, Swift pending in #89)
any_cast type-probe (C++ persist save) Expected (query) R1 Remove → non-throwing pointer any_cast<T>(&v) #87
E700 “system not quiescent” (persist save) Precondition error R2 + R3 throw where available; abort-with-message fallback #87
E703 single-driver violation (async) Contract error R2 + R3 throw/rejected-future where exceptions exist; C++ casing uses an RAII gate-guard + #if-guarded throw / abort fallback #88 done (C++)
nlohmann internal throws (persist) Library, errors Library self-switches to abort under -fno-exceptions; no Frame action n/a

Per-language error mechanism

The portable semantic “signal this error” lowers per target:

Mechanism family Targets Form
Thrown exception (+ R3 fallback where compilable-out) C++, Java, C#, Kotlin, Swift, Dart, Python, Ruby, PHP, TS, JS throw / raise; C++ adds the #if __cpp_exceptions fallback
Error value Rust (Result), Go (error) returned, never thrown
Fail-fast / code C, GDScript, Lua abort / return code / push_error
Process model Erlang gen_statem reply / crash-and-supervise

Drawbacks and alternatives

  • #if dual-path adds source. A few guarded lines per throw site. Accepted: exception-enabled builds are byte-identical, and the fallback is the price of cross-target compilability.
  • Alternative: forbid all generated throws. Rejected — it would force an error-value channel into exception-idiomatic languages, fighting their grain for no benefit, and contradicts how those languages’ own serializers work.
  • Alternative: ignore -fno-exceptions. Rejected — it blocks real targets (Godot web, #86), and “compiles only with the exception runtime” is exactly the silent trap R3 removes.

Relationship to other documents

  • The Exception Policy section of docs/frame_language.md is the normative user-facing summary; it cites this RFC for rationale.
  • RFC-0044 is the mechanism for R4 (context-stack cleanup) per backend.
  • RFC-0043 introduced E703, now classified here under R2 + R3.