Lesson 3.5
Error Handling
guard/against/throw -- Monk's alternative to try/catch, implemented with setjmp/longjmp.
Things go wrong.
Division by zero. File not found. Index out of bounds. Type mismatch. Every program encounters situations where it can't do what was asked. The question is: what happens next?
Some languages crash immediately. Some return error codes you have to check at every call site. Some use exceptions that silently propagate up the call stack. Monk takes a different approach: guard/against/throw.
Guard, against, throw.
The syntax makes the intent explicit. You're guarding a piece of code against possible errors:
guard result = might_fail() against error {
show("Something went wrong: " + error)
result = fallback_value
}
Read it aloud: "Guard this assignment. If it throws, catch the error in the against block." The variable error holds the error message. The against block provides recovery logic.
Throwing an error from anywhere in the call stack:
const divide = (a int, b int) int {
if b == 0 {
throw("division by zero")
}
return a / b
}
guard result = divide(10, 0) against error {
show(error) // "division by zero"
result = 0
} The throw doesn't have to be in the immediate function. It can be 5 calls deep. The error propagates back to the nearest guard.
Save points and time travel.
Think of it like saving your game. guard saves a checkpoint. Your program keeps going. If something goes wrong, throw loads that save. Everything that happened after the save is undone. You're back at the checkpoint, in the against block, with an error message telling you what went wrong.
If you never save your game (no guard), and something goes wrong, the game crashes. That's exactly what happens in Monk: unguarded errors terminate the program with a stack trace.
Monk's error handling is explicit. You must write guard to catch errors. If you don't guard, errors crash the program with a stack trace. No silent swallowing.
Under the hood: setjmp and longjmp.
C has a mechanism for this exact pattern: setjmp and longjmp. They're in the C standard library, and they do exactly what the game-save analogy describes.
setjmp saves the current position.
It captures the CPU state (registers, stack pointer, program counter) into a buffer. Returns 0 the first time. This is the checkpoint.
longjmp jumps back to the checkpoint.
It restores the saved CPU state and makes setjmp return a non-zero value. Execution resumes at the setjmp call, as if the intervening code never happened.
The return value tells you which path you're on.
First call (setjmp returns 0): run the guarded code. Second call (setjmp returns non-zero): an error was thrown, run the against block.
The conceptual C translation of guard/against looks like this:
// Conceptual C for: guard result = expr against error { ... }
jmp_buf checkpoint;
if (setjmp(checkpoint) == 0) {
// Normal path: evaluate the guarded expression
result = evaluate_expr();
} else {
// Error path: setjmp returned non-zero (longjmp was called)
MonkValue error = monk_current_error();
// ... run the against block ...
} And a throw is simply:
// Conceptual C for: throw("division by zero")
monk_throw("division by zero");
// internally calls longjmp(nearest_checkpoint, 1) Why not return codes?
The alternative to setjmp/longjmp is making every function return an error indicator. The caller checks it after every call. This is what Go does with if err != nil.
For a compiled language like Monk, return codes create a problem for code generation. Every function call in the generated C would need a check:
// With return codes: every call needs checking
MonkValue result = call_function(args);
if (has_error(result)) {
// propagate the error up...
}
// Now do the next thing
MonkValue result2 = call_another(result);
if (has_error(result2)) {
// propagate again...
} With setjmp/longjmp, the code generator doesn't need to emit error checks at every call site. Errors automatically propagate back to the nearest guard. The generated code stays clean:
// With setjmp/longjmp: clean generated code
MonkValue result = call_function(args);
MonkValue result2 = call_another(result);
// If either throws, longjmp jumps back to the guard Simpler codegen, simpler generated C, and the error handling semantics are the same.
The tradeoff: skipping cleanup.
setjmp/longjmp has a known weakness: when longjmp fires, it skips past any cleanup code between the throw site and the guard. In C++, this means destructors don't run. In a language with shared mutable state, this could leave things in an inconsistent state.
Monk sidesteps this problem through value semantics. Since there's no shared state, there's nothing to corrupt. When longjmp unwinds the stack, the local variables on each stack frame are simply abandoned. Heap memory for those values is leaked in the worst case, but the program's observable state remains consistent.
For a language prioritizing correctness and simplicity over maximum performance, this is an acceptable tradeoff.
Runtime functions for error handling.
monk_guard_begin_ctx() Sets a checkpoint with setjmp. Returns 0 on first call, non-zero when an error is caught. monk_throw(message) Stores the error message and calls longjmp to jump back to the nearest guard. monk_current_error() Returns the error message from the most recent throw.
The code generator emits calls to these functions. A Monk programmer never sees them -- they write guard, against, and throw. The mapping from Monk syntax to C runtime calls is the code generator's job.
Key takeaways
guard/against/throw is Monk's error handling. Explicit, not implicit. You must guard to catch.
Implemented with C's setjmp/longjmp. setjmp saves a checkpoint, longjmp jumps back to it.
Chosen over return codes because it simplifies code generation. No per-call-site error checks needed.
Value semantics makes the setjmp/longjmp tradeoff safe: no shared state to corrupt when the stack unwinds.