Error Handling
Maxon uses a unified error handling system based on typed errors. Functions either return a value or throw an error—there are no optional types or null values. Error types must be enums conforming to the Error interface.
Defining Error Types
Error types are enums that conform to the Error interface:
// Simple enum errorenum FileError implements Error notFound permissionDenied alreadyExistsend 'FileError'
// Int-backed enum error (for error codes)enum HttpError int implements Error badRequest = 400 notFound = 404 serverError = 500end 'HttpError'
// String-backed enum error (for messages)enum ValidationError String implements Error emptyField = "Field cannot be empty" invalidFormat = "Invalid format"end 'ValidationError'Note: Only enums can conform to Error. Attempting to make a type (struct) conform to Error produces a compile error (E023).
Throwing Functions
Functions that can throw errors declare the error type with throws:
function readFile(path String) returns String throws FileError if not exists(path) 'check' throw FileError.notFound end 'check' return contentsend 'readFile'
// Void function that throwsfunction resetConfig() throws FileError if not exists("config.json") 'check' throw FileError.notFound end 'check' // reset logic...end 'resetConfig'Syntax:
function name(params) returns ReturnType throws ErrorTypefunction name(params) throws ErrorType // void function that throwsThrow Statement
Use throw to throw an error value:
throw FileError.notFoundthrow HttpError.serverErrorRules:
throwis only valid inside functions with athrowsdeclaration- The thrown value must match the declared error type
Panic Statement
The panic statement immediately terminates the program with an error message and stack trace. It is used to signal unrecoverable errors — situations that represent bugs in the program rather than expected error conditions.
panic("something went wrong")The argument can be a plain string literal or an interpolated string. The program prints a panic message to stderr including the source file and line number, followed by a stack trace, then exits with code 1.
function processValue(x int) returns int if x < 0 'negative' panic("processValue: negative input, got {x}") end 'negative' return x * 2end 'processValue'Output when called with a negative value:
panic at example.maxon:3: processValue: negative input not allowedStack trace: in example.processValue in example.main in _startUse panic for invariant violations and unreachable code paths. For expected error conditions (invalid user input, missing files, etc.), use throw/try/otherwise instead.
Calling Throwing Functions
When calling a function that throws, you must use try:
// Compile error - must use trylet contents = readFile("config.json") // ERROR
// Correct - use try with otherwiselet contents = try readFile("config.json") otherwise ""The try keyword is always required when calling throwing functions, even when using otherwise.
Handling Errors with otherwise
The otherwise keyword provides unified error handling for throwing expressions. There are six forms:
Default Value Form
Provide a default value when an error occurs:
let value = try mayFail() otherwise 42If mayFail() throws, value is assigned 42. The default expression must match the return type.
function readConfig() returns String // If readFile throws, use empty string as default let contents = try readFile("config.json") otherwise "" return contentsend 'readConfig'Ignore Form
Discard errors when you don’t need the result:
try mayFail() otherwise ignoreThis silently ignores any thrown error. Use sparingly—typically for cleanup operations where errors can be safely ignored.
function cleanup() // Best-effort cleanup, ignore failures try deleteFile("temp.txt") otherwise ignoreend 'cleanup'Panic Form
Crash immediately if an error occurs. Use for unreachable error paths where a failure indicates a bug:
let slot = try slots.get(idx) otherwise panic("unreachable: index was validated")This is preferred over a silent default value when the error path should never execute. If it does, the program terminates with a stack trace rather than silently miscompiling or producing wrong results.
Single-Statement Form
Run a single statement on the error path. Supported statements are return, break, continue, and throw:
let value = try mayFail() otherwise return -1Each of these statements terminates the error path, so the success value still flows out of the try expression normally. Use single-statement form when the error handler is a single early exit — for anything more complex, use the block form.
// Early return on errorfunction runIt() returns int let value = try mayFail() otherwise return -1 return valueend 'runIt'
// Bail out of a loop on errorwhile true 'loop' let v = try next() otherwise break total = total + vend 'loop'
// Skip failed itemsfor item in items 'items' let parsed = try parse(item) otherwise continue results.append(parsed)end 'items'
// Re-throw as a different error typefunction outer() returns int throws OuterError let v = try inner() otherwise throw OuterError.failed return vend 'outer'Each statement has the same requirements it normally has: break/continue must be inside a loop, throw requires the enclosing function to declare throws, and return’s value must match the enclosing function’s return type.
Block Handler Form
Execute a block of code when an error occurs:
try readFile("config.json") otherwise 'handler' print("File not found, using defaults") useDefaults()end 'handler'The block executes only if an error is thrown.
function loadData() returns int var result = 0 try parseFile("data.txt") otherwise 'err' result = -1 // Mark as failed logError("Parse failed") end 'err' return resultend 'loadData'Block with Error Binding
Capture the error as a typed enum for inspection:
try readFile("config.json") otherwise (e) 'handler' match e 'check' notFound then print("File not found") permissionDenied then print("Permission denied") alreadyExists then print("Already exists") end 'check'end 'handler'The error is bound to the variable e as a typed enum value, allowing you to match on specific error cases. For error enums with associated values, you can extract the payload in the match arm.
function processFile(path String) try readFile(path) otherwise (err) 'handler' match err 'kind' notFound then print("File not found") permissionDenied then print("Permission denied") alreadyExists then print("Already exists") end 'kind' end 'handler'end 'processFile'The (e) binding is a regular local variable, so the standard unused-variable check (E3012) rejects a binding that is declared but never read inside the handler. If you only need to detect failure and have no use for the typed error value, drop the binding and use the bare block form try expr otherwise 'handler' ... end 'handler' instead.
Error Propagation
Use try without otherwise to propagate errors to the caller. This is only valid inside functions declared with throws:
function loadConfig() returns Config throws FileError // If readFile throws, the error propagates to our caller let contents = try readFile("config.json") return parse(contents)end 'loadConfig'Rules:
trywithoutotherwiseis only valid in functions withthrows- The propagated error type must be the same type as the enclosing function’s declared error type. Propagation re-throws the callee’s error value through the caller’s error flag, so a type mismatch would cause the caller to decode bits of one enum as tags of another. If the types differ, add an
otherwiseclause to convert. - Using
trywithoutotherwisein a non-throwing function is a compile error
Try Block (Multi-Call Error Handling)
The try 'label' ... end 'label' otherwise (e) 'handler' ... end 'handler' construct wraps a sequence of statements so that every throwing call inside funnels its error to a single shared handler. Inside the body, bare calls to throwing functions do not require the try keyword — the compiler implicitly promotes them.
try 'reading' let raw = readFile("config.json") let parsed = parseJson(raw) let port = parsed.get("port") print("{port}")end 'reading'otherwise (e) 'handler' match e 'kind' FileError.notFound then print("missing") FileError.permissionDenied then print("denied") ParseError.unexpectedToken then print("bad json") MapError.missingKey then print("no port") end 'kind'end 'handler'Rules:
- The
trybody MUST contain at least one bare call to a throwing function (E3083). - The
otherwiseclause takes one of three forms:- Block handler —
otherwise (e) 'handler' ... end 'handler'MUST contain amatchon the binding (E3084). - Terminal panic —
otherwise [(e)] panic("message")halts the program when the body throws. - Terminal throws —
otherwise [(e)] throws ErrorType.casere-throws a fixed error to the caller. The enclosing function must declarethrows ErrorType.
- Block handler —
- The
(e)binding is optional for the terminal forms. When supplied it has the same type as in the block-handler form (single enum or synthesized error union) and may be referenced inside the panic message’s interpolation or inside the throw expression — for example,otherwise (e) throws AppError.wrap(e)to wrap the original error as a payload of the new error case. A binding declared but never read is rejected by the standard unused-variable check (E3012). - If the body throws calls of a single error enum type,
ehas that enum type and match patterns use bare case names (e.g.notFound). - If the body throws calls of two or more distinct error enum types,
ehas a synthesized error-union type. Each match arm targets a specificEnumName.caseNamepair:- Fully-qualified form
EnumName.caseNameis always accepted. - Bare
caseNameis accepted only when the case name is unique across the union members. Shared names (e.g. two enums both withnotFound) are rejected with E3085.
- Fully-qualified form
- The match must be exhaustive across every
(EnumName, caseName)pair unless adefaultarm is provided. - An explicit
try expr otherwise ...inside the body still works for any single call — its error is consumed by its own handler and does not contribute to the synthesized union. - Nested try blocks compose: the inner block absorbs its own throws; the outer block sees only what its own bare calls throw. A terminal
otherwise throws E.xinside an inner try block routes through the outer block’s shared error sink, just like a barethrow.
Terminal Form Examples
// Panic when the body throws — useful for unreachable error paths.try 'reading' parseFile("data.json")end 'reading'otherwise panic("unreachable: data.json is bundled with the binary")
// Re-throw a fixed error to the caller.function compute() returns int throws AppError try 'work' doStuff() end 'work' otherwise throws AppError.failed return 0end 'compute'
// Bind the original error and wrap it as the payload of the new error.function compute2() returns int throws AppError try 'work' doStuff() end 'work' otherwise (e) throws AppError.wrap(e) return 0end 'compute2'Conditional Try (if try)
The if try construct provides conditional execution based on whether a throwing expression succeeds.
Boolean Form
Check if an expression succeeds without binding the result:
if try mayFail() 'check' print("Success!")end 'check'The if-block executes only if the expression succeeds (doesn’t throw).
Binding Form
Unwrap and bind the success value:
if let value = try mayFail() 'check' print("Got: {value}")end 'check'If successful, the unwrapped value is bound to value and available within the if-block.
Mutable Binding Form
Use var instead of let to make the bound name reassignable inside the then-block:
if var value = try mayFail() 'check' value = value + 10 return valueend 'check'The binding is scoped to the then-block; mutations do not propagate back to the source expression.
With Else Clause
Handle the error case:
if try mayFail() 'check' print("Success!")end 'check' else 'err' print("Failed!")end 'err'With Error Binding
Capture the error value in the else block:
if let value = try mayFail() 'check' print("Got: {value}")end 'check' else (e) 'err' print("Error occurred")end 'err'The error is bound to e and available within the else-block.
Standard Library Error Types
The standard library provides error types for built-in operations:
// Array access errorsenum ArrayError implements Error indexOutOfBounds // index >= length emptySlot // slot pointer is null (e.g. after resize() without push())end 'ArrayError'
// Map operationsenum MapError implements Error keyNotFound keyAlreadyExistsend 'MapError'
// Iterator exhaustionenum IterationError implements Error exhaustedend 'IterationError'
// File metadata errorsenum FileInfoError implements Error notFound // file does not existend 'FileInfoError'Array and Map access methods throw these errors:
var arr = [1, 2, 3]let val = try arr.get(5) otherwise 0 // Returns 0 on out of bounds
var map = ["key": 42]let result = try map.get("missing") otherwise -1 // Returns -1 if key not foundError Enum Types
Functions with throws return an error type internally:
// This function returns "String or FileError" internallyfunction readFile(path String) returns String throws FileErrorMemory Layout:
+--------+--------------------------------+| tag(8) | value OR error ordinal |+--------+--------------------------------+ 0=ok success value 1=err enum ordinal (8 bytes)Complete Example
enum ParseError implements Error invalidSyntax unexpectedEndend 'ParseError'
function parseNumber(s String) returns int throws ParseError if s.isEmpty() 'empty' throw ParseError.unexpectedEnd end 'empty' // parsing logic... return resultend 'parseNumber'
function main() returns ExitCode // Use default value on error let num1 = try parseNumber("42") otherwise 0
// Handle error in block var num2 = 0 try parseNumber("invalid") otherwise 'err' num2 = -1 end 'err'
// Handle with error binding try parseNumber("") otherwise (e) 'handler' match e 'kind' invalidSyntax then print("Invalid syntax") unexpectedEnd then print("Unexpected end of input") end 'kind' end 'handler'
return num1end 'main'