Error Handling
Maxon has two kinds of failure:
- Errors are expected outcomes — a missing file, invalid input. A function declares the error type it
throws, and every caller handles it withtry. There are no exceptions that unwind silently, no null values and no optional types. - Panics are bugs — a broken invariant, an out-of-range value reaching a checked place. A panic stops the program with a message and a stack trace, and cannot be caught.
Defining Error Types
Section titled “Defining Error Types”An error type is an enum or a union that implements Error:
typealias HttpCode = int(100 to 599)
enum FileError implements Error notFound permissionDenied alreadyExistsend 'FileError'
union FetchError implements Error timedOut status(code HttpCode)end 'FetchError'A union error carries data about the failure, read back with match. Only enum and union values can be
thrown; throwing a record is E3005 (throw requires an error enum value).
Throwing Functions
Section titled “Throwing Functions”A function that can fail declares its error type with throws, after any returns:
typealias Amount = int(i64.min to i64.max)
enum ParseError implements Error empty invalidSyntaxend 'ParseError'
function parseDigit(s String) returns Amount throws ParseError if s.isEmpty() 'empty' throw ParseError.empty end 'empty'
return match s 'digit' "0" gives 0 "1" gives 1 "2" gives 2 default throws ParseError.invalidSyntax end 'digit'end 'parseDigit'
function requireName(name String) throws ParseError if name.isEmpty() 'missing' throw ParseError.empty end 'missing'end 'requireName'throw is legal only in a function that declares throws, and the value must be of the declared type.
panic("message") stops the program. The message may be interpolated. The program writes the message with
its source location and a stack trace to stderr and exits with code 1:
typealias Amount = int(i64.min to i64.max)
function processValue(x Amount) returns Amount if x < 0 'negative' panic("processValue: negative input, got {x}") end 'negative'
return x * 2end 'processValue'
function main() returns ExitCode print("{processValue(-3)}\n") return 0end 'main'panic at main.maxon:5: processValue: negative input, got -3Stack trace: in processValue in main in mrt_startThe trace lists the call chain innermost first, up to 100 frames. The runtime raises the same kind of panic
for a failed range check, a negative shift count and i64.min / -1
(panic: integer overflow). A recursion that outgrows its thread’s stack stops with
panic: stack overflow and the same trace on every native target; on wasm32-wasi it is the engine’s own
trap.
Use panic for invariant violations and unreachable paths; use throw for conditions a caller should
handle.
Calling Throwing Functions
Section titled “Calling Throwing Functions”Every call to a throwing function is marked with try. A call without it is E3057 (throwing function requires try). try is followed by exactly one of:
- nothing — propagate the error to the caller (Error Propagation), or
- an
otherwiseclause that handles it.
Handling Errors with otherwise
Section titled “Handling Errors with otherwise”Default Value
Section titled “Default Value”let digit = try parseDigit(text) otherwise 0If the call throws, the expression takes the fallback value, which must have the call’s result type.
Ignore
Section titled “Ignore”try requireName(name) otherwise ignoreDiscards the error. Reserve it for best-effort work such as cleanup.
let slot = try slots.get(index) otherwise panic("unreachable: index was validated")Turns an error that cannot happen into a panic, instead of hiding it behind a made-up default.
Single Statement
Section titled “Single Statement”return, break, continue or throw on the error path:
typealias Amount = int(i64.min to i64.max)typealias StringArray = Array with String
enum AppError implements Error badInputend 'AppError'
function firstDigitOrMinusOne(text String) returns Amount let value = try parseDigit(text) otherwise return -1 return valueend 'firstDigitOrMinusOne'
function sumDigits(parts StringArray) returns Amount var total = 0 for part in parts 'each' let d = try parseDigit(part) otherwise continue // skip bad parts total = total + d end 'each'
return totalend 'sumDigits'
function strictDigit(text String) returns Amount throws AppError return try parseDigit(text) otherwise throw AppError.badInput // convert the errorend 'strictDigit'Each statement follows its usual rules: break and continue need a loop, throw needs a throws clause.
Block Handler
Section titled “Block Handler”try parseDigit(text) otherwise 'failed' print("could not parse {text}\n") return 1end 'failed'Block with Error Binding
Section titled “Block with Error Binding”otherwise (e) binds the error value for a match:
try parseDigit(text) otherwise (e) 'failed' match e 'kind' empty then print("no input\n") invalidSyntax then print("not a digit\n") end 'kind'end 'failed'The binding must be used (E3012); if the kind of error does not matter, use the plain block form.
Error Propagation
Section titled “Error Propagation”A bare try passes the error on to the caller. It is legal only in a function that declares throws with
the same error type:
function doubleDigit(text String) returns Amount throws ParseError let d = try parseDigit(text) // a ParseError propagates to our caller return d * 2end 'doubleDigit'- In a function with no
throwsclause it is E3059 (the error has nowhere to go). - When the callee’s error type differs from the function’s, it is E3059 (
try propagates 'E' but enclosing function throws 'Other' — add 'otherwise' to convert); convert withotherwise throw Other.case. - Inside a test, a bare
tryon any error type is allowed: an error that reaches it fails the test.
Try Blocks
Section titled “Try Blocks”A try block runs several statements and sends every error to one handler. Inside the block, calls to
throwing functions need no try of their own:
typealias Amount = int(i64.min to i64.max)
enum FileError implements Error notFoundend 'FileError'
enum ConfigError implements Error badPortend 'ConfigError'
function readConfig(name String) returns String throws FileError if name == "missing" 'absent' throw FileError.notFound end 'absent'
return "port=80"end 'readConfig'
function parsePort(text String) returns Amount throws ConfigError if text == "port=80" 'ok' return 80 end 'ok'
throw ConfigError.badPortend 'parsePort'
function load(name String) try 'reading' let raw = readConfig(name) let port = parsePort(raw) print("port {port}\n") end 'reading' otherwise (e) 'handler' match e 'kind' FileError.notFound then print("missing\n") ConfigError.badPort then print("bad config\n") end 'kind' end 'handler'end 'load'
function main() returns ExitCode load("app") // port 80 load("missing") // missing return 0end 'main'- The block must contain at least one call that throws (E3083).
- The
otherwiseclause is one of:- a handler block
otherwise (e) 'label' … end 'label', which mustmatchon the binding (E3084); otherwise [(e)] panic("message"), which panics if the block throws;otherwise [(e)] throws ErrorType.case, which throws a fixed error to the caller — the binding can be wrapped as a payload:otherwise (e) throws AppError.wrap(e).
- a handler block
- When the block’s calls throw one error type,
ehas that type and arms use bare case names. When they throw several,eis a combined error: arms nameErrorType.case, and a bare case name is accepted only when it is unique across the types (E3085 otherwise). The match is exhaustive over every pair unless it has adefaultarm. An arm may join cases withor, including cases of different types; an arm that names more than one type binds no payloads (E3129). - When one of those types is a union with payloads, every path through the handler must match
eexactly once (E3161): not twice, not inside a loop the handler opens (itswhilecondition included), and not on only some paths — areturn,throw,break,continueor propagated error before the match is refused, as is a match on one branch of anif, on the right ofand/or, or in an arm another arm falls through into, where the other path continues without one. Statements before the match are fine, and so is one match on each branch of anif/else. Matcheonce and bind what the rest of the handler needs in its arms. - A call inside the block with its own
try … otherwisehandles its own error, which does not reach the block’s handler. Nested try blocks compose the same way.
Conditional Try (if let … = try)
Section titled “Conditional Try (if let … = try)”if let runs a block only when a throwing call succeeds, binding its result:
if let value = try parseDigit(text) 'parsed' print("got {value}\n")end 'parsed' else (e) 'failed' match e 'kind' empty then print("no input\n") invalidSyntax then print("not a digit\n") end 'kind'end 'failed'if var value = try …makes the binding reassignable inside the block.- The
elseblock is optional, and so is its(e)binding. if try call() 'label', with no binding, is only for a call that returns nothing; testing a call that returns a value that way discards the value and is E3124.
Errors in Other Positions
Section titled “Errors in Other Positions”- A match arm or match expression can throw with
throws ErrorType.caseordefault throws(see Default Throws and Default Panic). - A promise from a throwing
asynccall is awaited withtry await(see Concurrency). - A division whose divisor might be zero throws
DivisionByZero(see Division by Zero). - A function type cannot declare
throws, so a throwing function cannot be used as a function value (E3101).
Standard Library Error Types
Section titled “Standard Library Error Types”| Error | Cases | Thrown by |
|---|---|---|
ArrayError |
indexOutOfBounds, emptySlot |
Array element access |
MapError |
keyNotFound, keyAlreadyExists |
Map.get, Map.insert |
IterationError |
exhausted, atStart |
iterators |
StringError |
notFound, invalidIndex |
String search and indexing |
ParseError |
invalidFormat |
int.fromString, float.fromString, bool.fromString |
DivisionByZero |
divisionByZero |
/ and mod with a divisor that may be zero |
| enum lookups | noSuchCaseName, noSuchRawValue |
fromName, fromRawValue |
The standard library reference lists the error types of each module.