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:

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:

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}`)

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:

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:

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:

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:

#!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}`)

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:

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")

'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

Next Steps

The next section, Enumerations, covers the enumerations used in this example in more detail.