Exception Handling in EK9
Every Exception in EK9 is unchecked: you cannot declare one as part of a method or function signature.
Exceptions are for 'exceptional' circumstances. EK9 can return values that are unset, and has Optional and Result, so you need Exceptions less.
A try has a single catch/handle, then an optional finally. There is no list of catches for different Exception types; to treat specific Exceptions differently, use the dispatcher.
Why EK9 does it this way. Unchecked Exceptions keep signatures clean and stop implementation details leaking out, at least at the point of declaration. The single catch/handle is a deliberate design decision: the syntax is more succinct, and it dovetails with the dispatcher mechanism.
Every Exception class you create must be derived from the standard Exception class. Exceptions are not for normal flow control; EK9 has plenty of other flow control mechanisms. An Exception is for 'exception flow control': when the normal, expected flow cannot be followed because of some extreme circumstance (out of memory, or the file system full, for example).
To process specific Exceptions:
- Avoid a 'case statement' over Exception types.
- Wherever possible, use polymorphic operators and methods on the Exception class.
- Where that is not possible, use the dispatcher to extract the details from the specific Exception, as the example below does.
Error Handling Strategy
EK9 has a layered error handling strategy. Each mechanism has its own purpose and context; choose the right one:
| Mechanism | Context | Purpose | Behaviour |
|---|---|---|---|
require |
Any code | Contract violations and preconditions | Uncatchable Panic. Program terminates with exit status 1 (finally blocks still run). Always active. |
throw / try-catch |
Any code | Exceptional but recoverable conditions | Catchable. Caller can handle and continue. Uncaught, it ends the program with its own exit code (else 1). |
| Result | Any code | Expected failures in normal flow | No exceptions. Four-state model with guards. |
| Optional | Any code | Value that may or may not be present | No exceptions. Two-state model with guards. |
assert |
@Test only | Verify test expectations | Compile error E81015 outside a dev source file, and E81012 where production code can reach it. A failed assert fails its test (ek9 -t exits 11). |
assertThrows |
@Test only | Verify exception is thrown | Compile error if used outside tests. |
assertDoesNotThrow |
@Test only | Verify no exception is thrown | Compile error if used outside tests. |
Decision guide:
- A test? Use
assert,assertThrowsorassertDoesNotThrow. - A programming error? Use
require. - Recoverable? Use
throwandtry-catch. - A failure that is expected and normal? Use Result or Optional.
The Testing page covers the require statement in detail, with
the test-only assertion keywords.
Try/Catch Exception Example
The example defines an enumerated type, two Exceptions of its own with extra properties and accessor methods, two classes that show what Exceptions can do, and a program that drives them. It is long, to give you a feel for how to use exceptions.
Example
defines constant missingFile <- "MightNotExist.txt" defines type BigCat Lion Tiger WildCat Leopard Lynx defines class AnException extends Exception supportingInformation <- String() AnException() -> primaryReason as String supportingInformation as String exitCode as Integer super(primaryReason, exitCode) this.supportingInformation :=: supportingInformation supportingInformation() as pure <- rtn as String: supportingInformation override operator $ as pure <- rtn as String: `${reason()} ${supportingInformation()} exit code ${exitCode()}` default operator ? default operator $$ OtherException extends Exception retryAfter <- DateTime() OtherException() -> reason as String retryAfter as DateTime super(reason) this.retryAfter :=: retryAfter retryAfter() as pure <- rtn as DateTime: retryAfter override operator $ as pure <- rtn as String: `${reason()} retry after ${$retryAfter()}` default operator ? default operator $$ ExceptionExample clock as Clock: SystemClock() deferProcessingUntilAfter <- DateTime() default private ExceptionExample() ExceptionExample() -> clock as Clock this.clock: clock checkExceptionHandling() -> aCat as BigCat <- didProcess <- false stdout <- Stdout() try if deferProcessing() stdout.println(`Deferred until after ${deferProcessingUntilAfter} ${aCat} not processed`) else triggerResult <- triggerPossibleException(aCat) stdout.println(triggerResult) didProcess: true catch -> ex as Exception stdout.println(handleException(ex)) finally stdout.println(`Finished checking ${aCat}`) triggerPossibleException() -> aCat as BigCat <- rtn as String: String() switch aCat case BigCat.Lion throw Exception($aCat, 1) case BigCat.Tiger throw AnException("Too dangerous", $aCat, 2) case BigCat.WildCat, BigCat.Lynx rtn: `Success with ${aCat}` case BigCat.Leopard throw OtherException($aCat, clock.dateTime() + PT2H) default rtn: `Success with ${aCat}` deferProcessing() <- rtn as Boolean: deferProcessingUntilAfter? <- deferProcessingUntilAfter > clock.dateTime() else false private handleException() as dispatcher -> ex as Exception <- rtn as String: $ex private handleException() -> ex as AnException <- rtn as String: $ex if ex.exitCode()? tidyUpReadyForProgramExit() private handleException() -> ex as OtherException <- rtn as String: $ex this.deferProcessingUntilAfter: ex.retryAfter() private tidyUpReadyForProgramExit() Stdout().println("Would tidy up any state ready for program exit") default operator ? FileExceptionExample demonstrateFileNotFound() stdout <- Stdout() //A failure while ACQUIRING a resource is not handled by the try that declares it; //it goes to the enclosing try. Once acquired, a resource is always closed. try try -> input <- openInput(missingFile) cat input > stdout //rather than use catch 'handle' can be used handle -> ex as Exception stdout.println($ex) finally stdout.println("Automatically closed file if opened") demonstrateNamedFileNotFound() -> fileName as String mainResults <- try -> input1 <- openInput(fileName) <- rtn as List of String: cat input1 | collect as List of String //Let the exceptions fly back - don't handle in here. Stdout().println(`Main Results ${mainResults}`) demonstrateFilesNotFound() mainResults <- try -> input1 <- openInput(missingFile) input2 <- openInput("AlsoMightNotExist.txt") <- rtn as List of String: cat input1, input2 | collect as List of String //Let the exceptions fly back - don't handle in here. Stdout().println(`Main Results ${mainResults}`) defines function //A missing file is not an Exception in EK9: the TextFile is just unset. //Here that is turned into an Exception, so the caller decides what it means. openInput() -> fileName as String <- rtn as CloseableStringInput? file <- TextFile(FileSystemPath().inCurrentWorkingDirectory(fileName)) if not file? throw Exception(`File Not Found: ${fileName}`) rtn: file.input() defines program TryCatchExample() stdout <- Stdout() //Rather than use SystemClock - simulate one so that date time can be altered. //This is a way of doing a 'Fake'/'Stub'. simulatedClock <- () with trait of Clock as class currentDateTime as DateTime: 1971-02-01T12:00:00Z override dateTime() as pure <- rtn as DateTime: currentDateTime override millisecond() as pure <- rtn as Millisecond: Millisecond(0) setCurrentDateTime() -> newDateTime as DateTime this.currentDateTime = newDateTime override operator ? as pure <- rtn <- true //use the simulated clock example1 <- ExceptionExample(simulatedClock) for cat in BigCat if example1.checkExceptionHandling(cat) stdout.println(`Processing of ${cat} was completed`) else stdout.println(`Processing of ${cat} was NOT completed`) //just try Lynx again require not example1.checkExceptionHandling(BigCat.Lynx) //alter the time just passed the retry after time. simulatedClock.setCurrentDateTime(simulatedClock.dateTime() + PT2H1M) //Now it should be processed. require example1.checkExceptionHandling(BigCat.Lynx) example2 <- FileExceptionExample() example2.demonstrateFileNotFound() try example2.demonstrateFilesNotFound() catch -> ex as Exception stdout.println(`TryCatchExample: ${ex}`)
Full source: exceptions_big_cats.ek9 - Download exceptions_big_cats.ek9
Results
Running 'TryCatchExample' writes everything to standard output, so you can follow the order in which the try, catch and finally blocks run, line by line:
Exception: Lion: Exit Code: 1 Finished checking Lion Processing of Lion was NOT completed Would tidy up any state ready for program exit Too dangerous Tiger exit code 2 Finished checking Tiger Processing of Tiger was NOT completed Success with WildCat Finished checking WildCat Processing of WildCat was completed Leopard retry after 1971-02-01T14:00:00Z Finished checking Leopard Processing of Leopard was NOT completed Deferred until after 1971-02-01T14:00:00Z Lynx not processed Finished checking Lynx Processing of Lynx was NOT completed Deferred until after 1971-02-01T14:00:00Z Lynx not processed Finished checking Lynx Success with Lynx Finished checking Lynx Exception: File Not Found: MightNotExist.txt Automatically closed file if opened TryCatchExample: Exception: File Not Found: MightNotExist.txt
Discussion
The example is a little contrived, but note that:
- A simulated clock (a dynamic class) is used for testing.
- handle can be used in place of catch; they mean the same.
- A try can stand alone, without catch/handle or finally.
- try and catch/handle can be used without finally.
- try and finally can be used without catch/handle.
- There can be only one catch/handle clause.
- A try can be used as an expression that returns a value.
- A try can take parameters that 'open' resources, and closes them automatically (when a close fails, see suppressed failures).
- A failure while acquiring a resource is not handled by the try that declares it: no resource is open yet, so it goes to the enclosing try (in 'demonstrateFileNotFound', the outer handle and finally). Once a resource has been acquired, it is always closed.
- A missing file is not an Exception in EK9: a TextFile that names no existing file is unset. The function 'openInput' turns that into an Exception, so its caller decides what it means.
Dispatcher
Because the dispatcher is part of the language, EK9 needs no 'casting' or 'instanceof' checks, and no type checks in the switch statement. As the example shows, you can still reach the extra methods and information of a specific class, and hold that information as state in your class if you need to.
Avoid calling the specific class methods when you can: note the overridden $ operator in the Exception classes.
As in most other languages with Exceptions:
- An Exception is thrown up the call stack until it is caught. The main program exits if it is not caught.
- The Exception class can hold an 'exit code'. If the Exception reaches the main program, the application exits with that code (after any finally blocks and application teardown have run). An uncaught Exception with no exit code exits with 1.
-
Only the Exception that reaches the main program counts. An Exception that wraps another (a cause)
uses its own exit code, never the cause's. So
Exception("Wrapped", cause)exits with 1, andException("Wrapped", cause, 3)exits with 3.
Exceptions, Panics and the Program's Exit Status
A program has no 'exit' call. Its exit status is decided in exactly one of four ways:
| How the program ends | Exit status | Notes |
|---|---|---|
| It finishes normally | 0 | |
| It returns an Integer | that value | Only an Integer may be returned (E07590); an unset return exits 1. |
| An Exception nothing catches | its exit code, else 1 | The exception that reaches the program decides; a cause's code is never used. |
| A Panic | always 1 | Raised only by the language: a failed require, a constrained-type value out of
range, an unset for-range bound. |
In both failing cases every finally block and the application teardown run first. Then one line is
written to stderr - Exception in thread "main" Exception: Fatal: Exit Code: 3 or
Exception in thread "main" Panic: Requirement failed: ... - with no Java stack trace.
Panic is not an Exception
A Panic is the language reporting a broken rule that the program's own logic should have prevented. It is a sibling of Exception, not a subtype, so:
- No catch can see it. A catch names an Exception, and
catch -> ex as Panicis E04030. The finally blocks still run. - Your code cannot create one, so it cannot throw one either. Panic has no constructors EK9 code can call,
and
Panic(...)(with any arguments, or none) is E50060. So you cannot choose a Panic's exit code; it is always 1. - To end a program with a failure status of your choosing, throw an Exception carrying that exit code and do not catch it. To report an outcome when the program finishes its work, return an Integer instead.
- In a test,
assertPanicis the way to observe a Panic.
Catching versus letting it end the program
A catch that handles an Exception ends that failure: the program carries on, and the exception's exit
code no longer applies (a program that only prints what it caught exits 0). To log it and still exit with its
code, throw ex again from the catch. A new Exception thrown without a code exits 1; give it
one, Exception("Import failed", ex, 4), to keep a code.
When the ek9 command runs the program, its exit status is passed through unchanged, and shares its numbers with the command's own exit codes - see Program Exit Codes vs CLI Exit Codes. Worked examples: ek9 -q 1431 to ek9 -q 1437.
When a close fails: suppressed failures
EK9 generates every close: at the end of a try with resources, at the end of a statement that made an unnamed closeable, and in the chain after a type's operator close. Each close runs even when an earlier one throws. The first Exception propagates (the try body's, when the body threw), and every later close failure is attached to it as suppressed. A close never replaces the real failure, and no failure is lost.
Read them with suppressed(), which every Exception (and every sub-type) has:
- It returns a List of Exception in the order the closes ran. The List is always set, and empty when nothing was suppressed, so it needs no guard.
- A type's close chain fails as one close: its first failure, with the rest of that chain suppressed onto it. So a suppressed failure can carry suppressed failures of its own.
- A Panic is never caught by a close, so it is never suppressed.
- An Exception that reaches the program uncaught prints each suppressed failure on its own
suppressed:line.
#!ek9 defines module site.exceptions.suppressed defines class Part name as String? default private Part() Part() -> label as String name: label operator close as pure stdout <- Stdout() stdout.println(`closing ${name}`) if name <> "totals" throw Exception(`close of ${name} failed`) default operator ? Ledger journal as Part: Part("journal") index as Part: Part("index") totals as Part: Part("totals") default operator close default operator ? defines function report() -> ex as Exception Stdout().println(`caught: ${ex.reason()}`) for failure in ex.suppressed() describe(failure, " ") describe() -> failure as Exception indent as String Stdout().println(`${indent}suppressed: ${failure.reason()}`) for inner in failure.suppressed() describe(inner, indent + " ") defines program InspectSuppressed() stdout <- Stdout() try try -> ledger <- Ledger() stdout.println("using the ledger") catch -> ex as Exception report(ex) try try -> ledger <- Ledger() require ledger? throw Exception("the posting failed") catch -> ex as Exception report(ex) try throw Exception("nothing to close") catch -> ex as Exception report(ex) stdout.println(`nothing suppressed: ${length ex.suppressed() == 0}`)
Download exceptions_suppressed.ek9
The output shows the first close failure caught with the second suppressed onto it; the posting's own failure kept, with the ledger's chain failure (and, within it, the other part's) suppressed; and an empty List for an exception that had nothing to close:
using the ledger
closing totals
closing index
closing journal
caught: close of index failed
suppressed: close of journal failed
closing totals
closing index
closing journal
caught: the posting failed
suppressed: close of index failed
suppressed: close of journal failed
caught: nothing to close
nothing suppressed: true
More: ek9 -q 1920 (inspecting suppressed failures) and ek9 -q 1902 (every close runs).
Summary
try, catch, finally and Exception look much like those in other languages. EK9 adds a number of features, removes 'multi-catch', and provides the dispatcher instead.
Why EK9 does it this way. Without multi-catch, processing a specific 'Exception' is either standard and simple, or delegated to class methods through the dispatcher. That may look inconvenient, but it forces a separation between receiving the 'Exception' and the logic that decides what to do about it.
Note: the example also uses require (near the end of 'TryCatchExample') to assert preconditions.
When require fails, the language raises an uncatchable Panic and
the program exits with status 1. See require on the Testing page.
A try can also start with:
- Assign-if-unset (:=?) - the try body runs only if the variable is set afterwards
- Assignments (: or :=) - blind, so the try body always runs
- Variable declarations (<-) - a declaration guard: the try body runs only if the value is set
For example:
defines function knownFileName() -> key as String <- rtn as String: String() if key == "report" rtn: missingFile defines program TryWithGuard() stdout <- Stdout() someVar as String? try someVar :=? knownFileName("unknown") stdout.println("TryWithGuard: the try body ran") FileExceptionExample().demonstrateNamedFileNotFound(someVar) catch -> ex as Exception stdout.println(`TryWithGuard: ${ex}`) stdout.println("TryWithGuard: carried on") TryWithAssignment() stdout <- Stdout() someVar as String? try someVar: knownFileName("report") stdout.println("TryWithAssignment: the try body ran") FileExceptionExample().demonstrateNamedFileNotFound(someVar) catch -> ex as Exception stdout.println(`TryWithAssignment: ${ex}`) TryWithDeclaration() stdout <- Stdout() try someVar <- knownFileName("unknown") stdout.println("TryWithDeclaration: the try body ran") FileExceptionExample().demonstrateNamedFileNotFound(someVar) catch -> ex as Exception stdout.println(`TryWithDeclaration: ${ex}`) //This is a 'different' 'someVar' - the previous one is now out of scope. someVar <- false require someVar? stdout.println("TryWithDeclaration: carried on")
Full source: exceptions_big_cats.ek9 - Download exceptions_big_cats.ek9
'knownFileName' returns an unset String for a key it does not know. Given such a key, the guard (:=?) and the declaration (<-) skip the whole try statement. The blind assignment (:) always runs it; here its key is known, so the body runs and the missing file is caught. 'TryWithGuard', 'TryWithAssignment' and 'TryWithDeclaration' print, in turn:
TryWithGuard: carried on
TryWithAssignment: the try body ran TryWithAssignment: Exception: File Not Found: MightNotExist.txt
TryWithDeclaration: carried on
The assignment and declaration forms work as they do in if, switch and for.
See Also
- require Statement - Uncatchable preconditions for contract enforcement
- Assertions -
assert,assertThrows,assertDoesNotThrowfor testing - Standard Types - Result and Optional for non-exceptional error handling
- Dispatcher - Type-safe routing without casting or instanceof
- Code Quality - Quality enforcement rules that apply to both production and test code
Next Steps
The next section, Enumerations, covers the enumerations used in this example in more detail.