Panic
panic is Kairo’s error signaling mechanism. A function marked with the panic specifier can produce
an error instead of its declared return type. Callers must handle the error via try/catch or
propagate it by marking themselves panic. The compiler statically verifies that all error types are
accounted for unhandled error types are a compile error.
Unlike C++ exceptions, panics use no unwinding tables and no runtime. The codegen is zero-cost every panic site becomes a tagged return value checked with a branch.
The panic Specifier
Add panic after the parameter list to indicate a function may produce an error:
fn parse_port(input: string) panic -> i32 {
if input.len() == 0 {
panic std::Error::Runtime("empty input")
}
var port = std::parse<i32>(input)
if port < 0 || port > 65535 {
panic std::Error::Runtime("port out of range")
}
return port
}
The panic keyword inside the function body raises an error. The error value can be any type
there is no base error class requirement.
Handling Panics with try/catch
Callers handle panics with try/catch. The compiler tracks every error type that can propagate
from the try body and verifies that all types are handled:
fn load_config(path: string) -> Config {
var port: i32
try {
port = parse_port(read_file(path))
} catch e: std::Error::Runtime {
std::println(f"bad config: {e}")
port = 8080
} catch e: std::Error::IO {
std::println(f"cannot read file: {e}")
port = 8080
}
return Config(port)
}
Exhaustiveness
The compiler enforces that every error type reachable from the try body is handled. If any type
is missing, it is a compile error unless the function is itself marked panic:
fn partial_handler() panic -> i32 {
// This function only handles Runtime errors.
// IO errors propagate to the caller.
try {
return parse_and_validate()
} catch e: std::Error::Runtime {
return -1
}
// std::Error::IO is not caught propagates because this function is marked panic
}
A bare catch (no type) acts as a catch-all and satisfies exhaustiveness for all remaining types:
fn safe_handler() -> i32 {
try {
return parse_and_validate()
} catch {
// handles any error type
return -1
}
}
Named and unnamed catch
The error value can be bound to a variable for inspection, or the catch block can omit the binding:
try {
risky_operation()
} catch e: std::Error::IO {
// e is bound can inspect the error
log_error(e)
} catch {
// no binding catch-all, error value is discarded
}
Propagation
If a function calls a panic function without fully handling all error types, it must be marked
panic itself. The unhandled errors propagate to the caller:
fn read_config() panic -> Config {
var content = read_file("config.txt") // may panic with IO error
var port = parse_port(content) // may panic with Runtime error
return Config(port)
// Both IO and Runtime errors propagate caller must handle them
}
Calling a panic function without try/catch and without the panic specifier is a compile
error:
fn bad() -> i32 {
return parse_port("8080") // compile error: parse_port may panic, but bad() is not marked panic
}
Multiple Error Types
A function does not declare which error types it can produce the compiler infers this from the function body. A single function can panic with any number of different error types:
fn process(path: string) panic -> Data {
if !file_exists(path) {
panic std::Error::IO("file not found")
}
var content = read_file(path)
if content.len() == 0 {
panic std::Error::Runtime("empty file")
}
if !validate(content) {
panic std::Error::Validation("invalid format")
}
return parse_data(content)
}
Callers of process must handle std::Error::IO, std::Error::Runtime, and
std::Error::Validation (plus any errors from read_file and parse_data), or propagate them.
try/catch as an Expression
try/catch can produce a value. Each branch must return the same type:
var port = try parse_port(input)
catch e: std::Error::Runtime { 8080 }
catch { 3000 }
finally is not permitted in expression form. See
Control Flow for expression-form rules.
finally
finally defines cleanup code that runs regardless of whether the try body succeeds or panics:
try {
acquire_lock()
do_work()
} catch e: std::Error::Runtime {
handle_error(e)
}
finally { // identical to standalone `finally`
release_lock() // always runs
}
Standalone finally (scope exit)
finally can appear without a preceding try. In this form it runs when the enclosing function
exits, regardless of how normal return, panic, or early return:
fn process_file(path: string) panic {
var fd = open(path)
finally {
close(fd)
}
var data = read(fd)
if !validate(data) {
return // finally still runs
}
transform(data)
// finally runs here too on normal exit
}
Multiple finally blocks in the same function execute in reverse declaration order (LIFO).
See Control Flow for full finally semantics.
Panic and No-Return (!)
A function with return type ! can never return normally it always panics, loops forever, or
calls another no-return function. The panic specifier and ! cannot coexist because panic
implies an alternative return path (the error), while ! guarantees no return at all:
fn fatal(msg: string) panic -> ! {
// compile error: panic and ! are contradictory
panic std::Error::Runtime("fatal error") // invalid, panic implies an error return, but ! means no return at all
}
fn fatal(msg: string) -> ! {
loop {
crash!(msg) // valid
}
// ok: never returns
}
See Functions and
Type System for ! semantics.
Codegen
Panics compile to zero-cost tagged return values. There are no unwinding tables, no runtime
exception handler, and no stack unwinding. A function marked panic returns a tagged union
containing either the success value or an error with source location metadata.
At the call site, try/catch compiles to a branch on the tag. If the tag indicates an error,
the catch block executes. If it indicates success, the value is extracted and execution continues.
This means:
- No runtime overhead on the success path beyond a single branch (which the branch predictor handles efficiently)
- No stack unwinding errors propagate via normal return values
- No unwinding tables in the binary smaller executables
- All functions in Kairo are trivially
noexceptat the ABI level
The panic statement inside a function body (panic SomeError(...)) does not halt the program.
It produces the error as the function’s return value. The term “panic” refers to the signaling
mechanism, not to a crash. The program only terminates if an error reaches main() or the top
level and is re-panicked with panic e in that context.
Error Types
Kairo does not prescribe a specific error hierarchy. Any type can be used as a panic value. The
standard library provides common error types under std::Error:
panic std::Error::Runtime("message")
panic std::Error::IO("message")
panic std::Error::Validation("message")
The std::Error hierarchy is still being finalized. Detailed documentation for standard error
types will be added in a future update.
User-defined error types work the same way:
class ParseError {
pub var message: string
pub var position: i32
fn ParseError(self, msg: string, pos: i32) {
self.message = msg
self.position = pos
}
}
fn parse(input: string) panic -> Ast {
if input.len() == 0 {
panic ParseError("unexpected end of input", 0)
}
// ...
}
try {
parse("")
} catch e: ParseError {
std::println(f"parse error at {e.position}: {e.message}")
}
Summary
// Function that may panic
fn divide(a: i32, b: i32) panic -> i32 {
if b == 0 {
panic std::Error::Runtime("division by zero")
}
return a / b
}
// Full handling no panic propagation
fn safe_divide(a: i32, b: i32) -> i32 {
try {
return divide(a, b)
} catch e: std::Error::Runtime {
std::println(f"error: {e}")
return 0
}
}
// Partial handling propagates unhandled types
fn partial(a: i32, b: i32) panic -> i32 {
return try complex_operation(a, b)
catch e: std::Error::Runtime { -1 }
// other error types propagate
}
// Expression form
var result = try { divide(10, 0) } catch { 0 }
// Scope exit
fn with_cleanup() panic {
var resource = acquire()
finally { release(resource) }
do_work(resource)
}