Error Handling Lands in Luma: No Try/Catch, Just .or()
Every language eventually needs to answer a question: what happens when something goes wrong?
Some languages answer with exceptions and try/catch blocks. Others use result types and pattern matching. A few punt the question entirely and leave you with null checks or error codes. Each approach has trade-offs, but they all share a common problem — they introduce new control flow that doesn’t look like anything else in the language.
We wanted Luma’s answer to feel like Luma. Method chains. Dot syntax. No new control flow to learn.
Today, error handling ships. Here’s what it looks like.
The core idea
Luma has four error-related concepts, and you only need to learn two of them to be productive:
error("message") // raise an error
.or(fallback) // handle itThat’s the entire mental model for most code. error() raises a recoverable error inside a function. .or() catches it and provides a fallback — the same way you’d chain any other method in Luma.
Raising errors
Inside any function, error("...") stops execution and signals a problem:
fn divide(a: int, b: int) -> int {
if b == 0 {
error("Division by zero")
}
return a / b
}error() is a statement, like return. You don’t write return error(...) — the function stops right there. The message is a string that describes what went wrong.
Handling errors with .or()
The simplest form provides a fallback value:
x: int = divide(10, 0).or(0)
print(x) // 0If divide errors, x gets 0. If it succeeds, x gets the result. One line, no branching.
This is the pattern that covers most real-world error handling. You call something that might fail, and you provide a safe default.
When you need the error message
Sometimes a flat fallback isn’t enough. The lambda form gives you access to the error:
x: int = divide(10, 0).or(err -> {
print("Failed: ${err}")
return 0
})The lambda receives the error message as a string. You can log it, transform it, or compute a different fallback. Same .or(), just with more control.
For simple cases, the expression form keeps things compact:
x: int = divide(10, 0).or(err -> -1)Same rules as lambdas everywhere else in Luma — expression body or block body.
Propagating errors with try
When you’re inside a function and don’t want to handle an error yourself, try passes it to your caller:
fn compute(a: str) -> int {
x: int = try parse_int(a)
return x * 2
}
result: int = compute("bad").or(-1)
print(result) // -1try is shorthand for “if this errors, I error too.” The error propagates up the call chain until someone handles it with .or() or it reaches the top of the program.
What happens when nobody handles it
If an error reaches main without being caught:
x: int = divide(10, 0)
print(x)The program prints a clear message and exits:
Error: Division by zeroNo stack trace. No panic dump. Just the message you wrote, on stderr, with exit code 1. This is the safe default — errors never disappear silently. If you forgot to add .or(), you’ll know immediately, and the message tells you exactly what to do.
Why not try/catch?
Try/catch is powerful, but it comes with baggage.
In most try/catch languages, error handling looks fundamentally different from everything else. You declare a block, catch exceptions by type, decide whether to re-throw, and wrap everything in boilerplate that obscures the happy path. The control flow jumps around. New programmers struggle with it. Experienced programmers argue about when to catch and when to propagate.
Luma’s approach is different because it reuses what you already know. .or() is a method call. Lambdas work the same way they work in filter() or walk(). There are no new keywords for catching, no new block syntax, no exception hierarchies. The error model is just another thing you can chain.
This has a practical consequence: error handling in Luma looks like normal code because it is normal code. The happy path reads left to right, and the fallback is right there next to it.
.or() works on optionals too
One of the design choices we’re most satisfied with is that .or() unifies error handling and optional handling:
name: ?str = find_user(42)
safe: str = name.or("guest")Optionals represent expected absence. Errors represent unexpected failure. But the handling is identical — you chain .or() and provide a fallback. One method, two use cases, no extra concepts to learn.
| Concept | Meaning | Handling |
|---|---|---|
Optional (?T) |
Value might be absent | .or(fallback) |
| Error | Something went wrong | .or(fallback) |
How it works under the hood
For the curious: Luma compiles to Go, and the implementation uses Go’s panic/recover machinery.
error("msg") compiles to panic(LumaError{Message: "msg"}). A LumaError is a simple struct — distinct from Go’s built-in panics, so we can tell them apart.
.or() compiles to an immediately-invoked closure with defer/recover. It catches LumaError panics and returns the fallback, while re-panicking on anything else (like a real bug). This means .or() is safe — it only catches Luma errors, never masks a nil pointer or index-out-of-bounds.
try compiles to nothing at all. It’s a no-op at the Go level because panics naturally propagate up the call stack. try exists for readability — it documents the programmer’s intent without adding runtime cost.
The main function gets a defer/recover wrapper that catches any unhandled LumaError and prints a clean message to stderr instead of a Go stack trace.
What we didn’t add
There are features we intentionally left out of this first version:
Custom error types. Right now, errors carry a string message. That’s enough for most use cases and keeps the model simple. Structured error types (with fields, codes, and hierarchies) may come later if real-world usage demands them.
Error wrapping. Adding context to an error as it propagates ("loading config: file not found") is a useful pattern, but it requires designing a wrapping API that feels natural in Luma’s chain syntax. We’d rather get that right than ship something we’ll regret.
Typed error matching. Catching different error types in .or() (like catch (IOException e) in Java) isn’t possible yet. For now, you get the message string and can inspect it. This is a deliberate simplification — most small programs don’t need error type dispatch, and the ones that do can use if on the message.
These are all reasonable future additions. But the foundation — error(), .or(), try, and the main recover wrapper — is solid and covers the vast majority of practical error handling.
The full picture
| Syntax | What it does |
|---|---|
error("msg") |
Raise an error inside a function |
.or(value) |
Fallback value if error occurs |
.or(err -> expr) |
Handle error with expression lambda |
.or(err -> { ... }) |
Handle error with block lambda |
try expr |
Propagate error to caller |
panic("msg") |
Crash immediately, not catchable |
No try/catch blocks. No exception hierarchies. No match statements. Just .or() — the same dot-method chaining Luma uses for everything else.
Error handling that looks like the rest of the language, because it is.