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-exceptionspersist), #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:
- 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.
- 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
throwthat 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, aResult).
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() -> error — values, 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
#ifdual-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.mdis 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.