EK9 in Depth

Errors and Exceptions

Making Decisions and Repeating Work listed exceptions among EK9's ways of controlling flow, "when something goes wrong", and left them for this volume. Optional and Result covered the failure you expect; this chapter covers the one you do not. If you write Java, C# or Python you will find try, catch1, finally and throw where you expect them, and you will write your first handler without help. Four things differ, and the rest of the chapter is those four: a try has exactly one catch with one parameter, and a family of exceptions is routed by a dispatcher instead of a ladder; a try can be an expression; the construct that owns a resource closes it, you cannot, and it does so after your catch and finally have run; and there is a third kind of failure, the Panic, that no catch can see.

Which failure is which

Rust splits failure into Result and panic!, and Go into error and panic. Java splits it into checked and unchecked exceptions, and then spends a language's worth of argument on which to use. EK9 splits it three ways, and the split is about who is expected to act on the failure:

The failure What you write Who sees it
One the caller should expect and branch on Result or Optional; ignoring one is E11052 The caller, at the call, in the same block
One the caller cannot sensibly continue past throw an Exception The nearest enclosing catch; otherwise the process ends with the exception's exit code
A broken contract: require, a constraint violation A Panic, raised by the language Nothing you can write; the process ends with status 1

Nothing in a function's signature says what it may throw. There is no throws to maintain and no checked exception to wrap and rewrap, which is also why the choice between the first two rows is a judgement the compiler cannot make for you. The cost section at the end of the chapter gives a workable way to make it.

Throwing, and catching in one place

A user exception extends Exception, which is closed to extension by default like every EK9 type, so a base you mean to extend says as open. This till charges a card and can fail three ways: a card it has never heard of, a balance too low, and an amount that should not have been asked for at all. The first two are payment failures with details; the third is a plain Exception.

#!ek9
defines module book.payments.exceptions

  defines constant
    goldCard <- "4111"
    silverCard <- "5500"
    unknownCard <- "9999"

  defines class

    PaymentException extends Exception as open
      cardNumber <- String()

      PaymentException()
        ->
          reason as String
          cardNumber as String

        super(reason)
        this.cardNumber :=: cardNumber

      cardNumber() as pure
        <- rtn as String: cardNumber

      default operator ?
      default operator $
      default operator $$

    CardDeclined extends PaymentException
      declineCode <- Integer()

      CardDeclined()
        ->
          cardNumber as String
          declineCode as Integer

        super("Card declined", cardNumber)
        this.declineCode :=: declineCode

      declineCode() as pure
        <- rtn as Integer: declineCode

      default operator ?
      default operator $
      default operator $$

    InsufficientFunds extends PaymentException
      shortfall <- Integer()

      InsufficientFunds()
        ->
          cardNumber as String
          shortfall as Integer

        super("Insufficient funds", cardNumber)
        this.shortfall :=: shortfall

      shortfall() as pure
        <- rtn as Integer: shortfall

      default operator ?
      default operator $
      default operator $$

    Till
      balances as Dict of (String, Integer): {goldCard: 300, silverCard: 50}

      pay()
        ->
          cardNumber as String
          amount as Integer
        <- rtn as String: String()

        try
          rtn: charge(cardNumber, amount)
        catch
          -> ex as Exception
          rtn: handleFailure(ex)

      private charge()
        ->
          cardNumber as String
          amount as Integer
        <- rtn as String: `charged ${amount} to card ${cardNumber}`

        if amount <= 0
          throw Exception(`amount must be positive, not ${amount}`)
        balance <- balances.getOrDefault(cardNumber, Integer())
        if not balance?
          throw CardDeclined(cardNumber, 51)
        if balance < amount
          throw InsufficientFunds(cardNumber, amount - balance)
        balances += DictEntry(cardNumber, balance - amount)

      private handleFailure() as dispatcher
        -> ex as Exception
        <- rtn as String: `unexpected: ${ex.reason()}`

      private handleFailure()
        -> ex as PaymentException
        <- rtn as String: `payment failed on card ${ex.cardNumber()}: ${ex.reason()}`

      private handleFailure()
        -> ex as CardDeclined
        <- rtn as String: `card ${ex.cardNumber()} declined with code ${ex.declineCode()}`

      default operator ?

  defines program

    PaymentExceptions()
      stdout <- Stdout()
      till <- Till()

      stdout.println(till.pay(goldCard, 120))
      stdout.println(till.pay(unknownCard, 10))
      stdout.println(till.pay(silverCard, 80))
      stdout.println(till.pay(goldCard, 0))
      stdout.println(till.pay(goldCard, 500))

It prints:

charged 120 to card 4111
card 9999 declined with code 51
payment failed on card 5500: Insufficient funds
unexpected: amount must be positive, not 0
payment failed on card 4111: Insufficient funds

Read pay first. It has one try and one catch, and the catch takes one parameter typed as Exception, the nearest super type of everything charge can throw. It does not choose a response; it hands the exception to handleFailure, which is declared as dispatcher:

private handleFailure() as dispatcher
  -> ex as Exception
  <- rtn as String: `unexpected: ${ex.reason()}`

private handleFailure()
  -> ex as PaymentException
  <- rtn as String: `payment failed on card ${ex.cardNumber()}: ${ex.reason()}`

private handleFailure()
  -> ex as CardDeclined
  <- rtn as String: `card ${ex.cardNumber()} declined with code ${ex.declineCode()}`

A dispatcher is the call routed by the run-time type of its argument (The Dispatcher is the chapter on how). Here that gives three outcomes in the output. CardDeclined has an overload of its own, and card 9999 declined with code 51 came from it. InsufficientFunds has none, so the call went to the nearest overload above it, PaymentException's, which is why two lines read payment failed on card. And the plain Exception for the zero amount went to the dispatcher's own body, the unexpected: line, which is the default for anything no overload names. Add another sub-type of PaymentException tomorrow and it is routed to the nearest overload above it with no change to pay.

That is the replacement for the catch ladder, and EK9 refuses the ladder rather than discouraging it. A second catch is a syntax error:

catch
  -> ex as CardDeclined
  stdout.println(`declined: ${ex.reason()}`)
catch
  -> ex as Exception
  stdout.println(`failed: ${ex.reason()}`)
Syntax  : '
        ' on line 30 position 11: unexpected '\n        ' expecting '.' or '::'
Syntax  : 'indentation' on line 31 position 8: mismatched input indentation expecting valid statement or expression
Syntax  : '.' on line 32 position 14: mismatched input '.' expecting 'pure', 'as', 'with', 'dispatcher', 'abstract', '(' or newline
Syntax  : 'dedentation' on line 34 position 0: extraneous dedentation expecting end of file or newline
Unable to parse source file [.../twoCatches.ek9]

The message is the parser's, not a teacher's: it reads the second catch as a name at the start of an ordinary statement, expects a . or :: after it, and complains about what follows. A catch with several parameters, the shape a Java multi-catch suggests, gets a plainer message:

catch
  ->
    first as Exception
    second as Exception
    third as Exception
  stdout.println(first.reason())
EK9     : Error   : E07850: '->' on line 19 position 8: only a single Exception is supported
             See: ek9 -h E07850

There is one first-attempt trap in the hierarchy. An exception sub-type that adds a field inherits $ and $$ from Exception, and the inherited ones were written for the base's fields. Leave the operators out and the compiler says so (E07238):

PaymentException extends Exception as open
  cardNumber <- String()

  PaymentException()
    ->
      reason as String
      cardNumber as String

    super(reason)
    this.cardNumber :=: cardNumber

  default operator ?
EK9     : Error   : E07238: 'PaymentException' on line 6 position 4: 'PaymentException' has 1 field(s) of its own but would inherit '$' from 'Exception', '$$' from 'Exception' - declare 'default operator $', 'default operator $$' (or write the operator): a type that adds fields, concrete or abstract, must declare its own '$', '$$' and ':=:', and its own sameness ('<=>' or '==', with '#?'), where it would inherit one: the inherited operator was written for the super's fields and cannot know these - use 'default operator' for each (or the bare 'default operator') or write one
             See: ek9 -h E07238

The three default operator lines in PaymentExceptions are the fix. The message is long because it states the whole rule: a type that adds state declares its own $, $$, :=: and sameness. The operator model behind it is the subject of Operators and Pragmatic Purity, in Full.

A narrower catch lets the rest through

The parameter's type is also a filter. A catch -> ex as CardDeclined catches a CardDeclined and its sub-types, and any other exception travels on to the enclosing try, or out of the program. Selective handling exists; what does not exist is a second clause on the same try. Two handlers for two types is two try statements, the narrow one inside:

NarrowCatch()
  stdout <- Stdout()
  try
    for amount in [0, -1, 5]
      try
        stdout.println(`charged ${charge(amount)}`)
      catch
        -> ex as CardDeclined
        stdout.println(`declined here: ${ex.reason()}`)
  catch
    -> ex as Exception
    stdout.println(`escalated: ${ex.reason()}`)
declined here: nothing to charge
escalated: amount must not be negative

The inner catch took the CardDeclined and the loop went on. The plain Exception for -1 was not its type, so it left the inner try, left the loop, and was caught by the outer one; 5 was never charged. Inside any catch, throw ex throws what you were given on again, for the cases where you want to log a failure where it happened and still let it climb.

try as an expression

When the point of a try is a value, the try can produce it. The returning variable is declared inside the try, its initialiser is inside the protected region, and the catch assigns it a replacement:

#!ek9
defines module book.try.expression

  defines constant
    fallbackPort <- 8080

  defines function

    portFrom()
      -> text as String
      <- rtn as Integer: Integer(text)
      if not rtn?
        throw Exception(`not a port number: ${text}`)

  defines program

    TryExpression()
      -> configured as String
      stdout <- Stdout()

      port <- try
        <- rtn as Integer: portFrom(configured)
      catch
        -> ex as Exception
        stdout.println(`using the fallback: ${ex.reason()}`)
        rtn: fallbackPort

      stdout.println(`listening on ${port}`)

Run it with a good argument and with a bad one:

listening on 9090
using the fallback: not a port number: oops
listening on 8080

Kotlin's try is an expression for the same reason. The fallback is a named constant, not a literal in the catch, because fallbackPort is a number with a reason. Use this form when the substitute value is a real answer. If the failure is one the caller ought to hear about, you wanted a Result.

Resources the compiler closes

Java has try-with-resources, C# has using, Python has with. Each exists because a hand-written close() is forgotten on the path that is tested least. EK9 goes one step further than closing for you: it will not let you write the close. A closeable is a type with an operator close; a try that is handed one owns it and closes it on every exit.2 Writing the close yourself is refused, whichever way you spell it:

CloseStatement()
  try
    -> conn <- Connection("ledger")
    conn.send("debit 100")
    close conn
EK9     : Error   : E01094: 'close' on line 28 position 8: 'close' is not a statement in EK9 - closing is generated: declare 'default operator close' (or write 'operator close' for extra work); a local closes through try or its statement
             See: ek9 -h E01094
CloseCall()
  try
    -> conn <- Connection("ledger")
    conn.send("debit 100")
    conn.close()
EK9     : Error   : E81060: 'close' on line 28 position 13: 'Connection.close()': the close operator is never called as a method, 'super.close()' included - make the value a try-with-resources resource; a type's own properties and its super class are closed by its generated close
             See: ek9 -h E81060

The first is the statement form, which EK9 does not have. The second is the operator called as a method, and the compiler gives its reason: a type's own properties and its super class are closed by its generated close. Opening a closeable into an ordinary local, with no try around it, is the third refusal:

LocalCloseable()
  conn <- Connection("ledger")
  conn.send("debit 100")
EK9     : Error   : E81056: 'conn' on line 25 position 6: 'conn' (declare it as 'try' then '-> conn <- ...'): a closeable value held in a named local must be a try-with-resources resource
             See: ek9 -h E81056

So a closeable lives where a try or a holder puts it, and nowhere else. The operator close in these examples is declared as pure, because it only prints, and a pure function may print.

The order, which is not Java's

Here is the order of events in the cases that matter: a body that completes, a body that throws, and a second resource that cannot be opened. Every one has a catch and, where it matters, a finally.

#!ek9
defines module book.resources.order

  defines constant
    firstLabel <- "first"
    secondLabel <- "second"
    brokenLabel <- "broken"

  defines class

    Connection
      name <- String()

      Connection()
        -> label as String
        if label == brokenLabel
          throw Exception(`cannot open ${label}`)
        name := label
        Stdout().println(`opened ${name}`)

      operator close as pure
        Stdout().println(`closed ${name}`)

      default operator ?

  defines program

    ResourceOrder()
      stdout <- Stdout()

      stdout.println("--- the body completes, with catch and finally ---")
      try
        ->
          first <- Connection(firstLabel)
          second <- Connection(secondLabel)
        stdout.println("body done")
      catch
        -> ex as Exception
        stdout.println(`catch: ${ex.reason()}`)
      finally
        stdout.println("finally")

      stdout.println("--- the body throws, with catch and finally ---")
      try
        ->
          first <- Connection(firstLabel)
          second <- Connection(secondLabel)
        throw Exception("body failed")
      catch
        -> ex as Exception
        stdout.println(`catch: ${ex.reason()}`)
      finally
        stdout.println("finally")

      stdout.println("--- the second resource cannot be opened ---")
      try
        try
          ->
            first <- Connection(firstLabel)
            second <- Connection(brokenLabel)
          stdout.println("never reached")
        catch
          -> ex as Exception
          stdout.println(`inner catch: ${ex.reason()}`)
      catch
        -> ex as Exception
        stdout.println(`outer catch: ${ex.reason()}`)
--- the body completes, with catch and finally ---
opened first
opened second
body done
finally
closed second
closed first
--- the body throws, with catch and finally ---
opened first
opened second
catch: body failed
finally
closed second
closed first
--- the second resource cannot be opened ---
opened first
closed first
outer catch: cannot open broken

Read the first two blocks against what Java does. In Java the resources are closed before catch and finally run. In EK9 the body runs, then the try's own catch, then its finally, and then the resources are closed, last opened first (closed second before closed first), and only then does the exception, if there still is one, leave the try. A catch therefore runs while the resources are still open. If you write from Java intuition you will have this backwards.

The third block is the other half. A failure while acquiring a resource is not handled by the try that declares it. The inner catch printed nothing; first was already open, so it was closed, and the exception went to the enclosing try.

A holder of closeables does not close them by hand either. A class that keeps closeables in its properties says default operator close, and the compiler writes the close for it, last property first. This is Session from the example that accompanies this chapter, which has its own Connection that opens and closes by printing, as the one above does:

Session
  primary <- Connection("primary")
  replica <- Connection("replica")

  send()
    -> message as String
    Stdout().println(`sending ${message}`)

  default operator close
  default operator ?
opened primary
opened replica
sending hello
caught link dropped
finally
closed replica
closed primary

session is the resource; it was closed after catch and finally, and it closed replica before primary.

When the close itself fails

A close can throw. That failure leaves the try after the try's own catch has run, which has a consequence worth seeing before it surprises you in production: a catch on the same try does not see it. It goes to the enclosing try, and if the body had already failed, the close failure does not replace the first exception. It is attached to it and read through suppressed(), as in Java:

#!ek9
defines module book.resources.suppressed

  defines class

    Journal
      name <- String()

      Journal()
        -> label as String
        name := label

      post()
        -> entry as String
        Stdout().println(`${name} posts ${entry}`)

      operator close as pure
        throw Exception(`close of ${name} failed`)

      default operator ?

  defines function

    report()
      -> ex as Exception
      stdout <- Stdout()
      stdout.println(`caught: ${ex.reason()}`)
      for failure in ex.suppressed()
        stdout.println(`  suppressed: ${failure.reason()}`)

  defines program

    SuppressedClose()
      stdout <- Stdout()

      stdout.println("--- the body completes, the close fails ---")
      try
        try
          -> journal <- Journal("audit")
          journal.post("debit 100")
      catch
        -> ex as Exception
        report(ex)

      stdout.println("--- the body fails, then the close fails ---")
      try
        try
          -> journal <- Journal("audit")
          journal.post("debit 100")
          throw Exception("posting failed")
      catch
        -> ex as Exception
        report(ex)
--- the body completes, the close fails ---
audit posts debit 100
caught: close of audit failed
--- the body fails, then the close fails ---
audit posts debit 100
caught: posting failed
  suppressed: close of audit failed

The inner try has no catch on purpose; the outer one is what receives the close failure. In the second case the exception the handler sees is the body's, posting failed, and the close failure is in its suppressed() list.

What may not hold a resource

A resource is closed when its try ends, so it may not be given to something that keeps it. Here a list outlives the try:

remember()
  ->
    remembered as List of Handle
    handle as Handle

  remembered += handle

audit()
  <- rtn as Integer: 0
  remembered <- List() of Handle
  try
    -> connection <- Connection("ledger")
    remember(remembered, connection)
  rtn: length remembered
EK9     : Error   : E81075: 'connection' on line 43 position 29: 'connection' is closed when its try ends, but it is given to a call that keeps it: a try resource is closed when its try ends, so it must not be given to a call that keeps it - keep the resource inside the try, pass a value taken from it, or make the holder a closeable that owns it
             See: ek9 -h E81075

The compiler reads the callee to decide this; remember adds its argument to a list the caller holds, so the list would have held a closed connection. The fix is to keep something taken from the resource, a String rather than the connection itself. The same rule applies to an unnamed closeable passed to a call that may hand it back (E81072), and an exception may not have an operator close at all (E81074): an exception is a value carried to a handler, not a resource.

What a catch cannot see

The third row of the table is the Panic. A failed require raises one, and so does breaking a constrained type's range. It is not an Exception, so no catch can name it:

try
  stdout.println(`Average: ${averageOf(List() of Integer)}`)
catch
  -> ex as Panic
  stdout.println("cannot get here")
EK9     : Error   : E04030: 'ex' on line 21 position 11: wrt 'ex as Panic': type must be of Exception type
             See: ek9 -h E04030

The point is not that the compiler is hiding the failure. A Panic means the program's own contract was wrong, and a handler that carries on would be carrying on from a state nobody can describe. Running one shows the shape:

try
  averageOf(noScores)
catch
  -> ex as Exception
  stdout.println("a catch never sees a Panic")
finally
  stdout.println("finally still ran")
Average: 6
Averaging an empty list
finally still ran
Exception in thread "main" Panic: Requirement failed: `not scores.empty()` at ./RequireExit.ek9:9:7: Exit Code: 1

The finally ran; the catch did not. The process ended with status 1, and the Panic line carries the file, line and column of the require.

An exception nothing catches ends the program too, and its exit code is the program's exit status. Exception takes an optional exit code, and this program throws one with 3:

loadSettings()
  -> fileName as String
  <- rtn as String?
  if not fileName?
    throw Exception("No settings file named", 3)
  rtn: `Loaded ${fileName}`
Loaded app.properties
Loading settings with no file name
Cleanup ran before the process ended
Exception in thread "main" Exception: No settings file named: Exit Code: 3

The finally ran again, then the process ended with status 3. An exception with no code ends the process with status 1. The same status is how a program reports a result to a script when nothing went wrong: a program that returns an Integer exits with it.

#!ek9
defines module book.exit.returned

  defines program

    ReturnedExit()
      -> rows as Integer
      <- exitCode as Integer: 0
      stdout <- Stdout()
      stdout.println(`rows found: ${rows}`)
      exitCode: rows == 0 <- 2 : 0

Given 0 rows it prints rows found: 0 and exits with status 2; given 7, it prints rows found: 7 and exits with 0.

Exceptions inside a pipeline

A stage is an ordinary function, so an exception thrown in one travels as any other does. Two things can happen to it. A try around the whole pipeline statement catches it, and the pipeline stops with nothing collected. Or a stage can catch inside itself and substitute a value, after which the pipeline carries on:

parsePriceOrZero()
  -> text as String
  <- rtn as Integer: 0
  try
    rtn: parsePrice(text)
  catch
    -> ex as Exception
    Stdout().println(`skipped: ${ex.reason()}`)
pipeline stopped: not a price: oops
skipped: not a price: oops
recovered: [12, 0, 30, 45]

The first pipeline stopped at oops and collected nothing, which is why the unreachable: line never printed. The second recovered: skipped: came out once, and 0 stands in for the price that would not parse. Stream Pipelines in Depth covers what else a stage can do about a failure, and Optional and Result, in Depth is the way to make the failure a value instead.

The cost

Three things in this chapter cost you something, and each buys something.

You cannot choose between Result and an exception by looking at the compiler's output, because the compiler does not mind which you pick. A workable rule is to ask what the caller will do next. If the caller will branch, ask for a Result: parsing a port number from a form is a thing that fails every day and the caller wants to say so to the user. If the only sensible response is to give up the operation and report, throw: a till asked to charge a negative amount has been called wrongly. The classic sin is the exception used as control flow, a try around a loop to find out when a lookup ran dry, and EK9 cannot stop you. It can make it clumsier than the alternative, since a try carries one catch and one parameter.

You cannot write a catch ladder. Two handlers for two unrelated types means two nested try statements, as in NarrowCatch, or one catch handing the exception to a dispatcher, as in PaymentExceptions; either is more ceremony than Java for the one case where a ladder is exactly what you want. What it buys is that the order of handlers is no longer part of the program's meaning, and that adding a sub-type needs no change at any catch.

You cannot close a resource at a moment of your choosing. A closeable lives to the end of the try that was handed it, or the end of the holder that owns it, and a design that wants the handle to outlive both has to be shaped around that. What it buys is that the leak has no spelling: there is no close to forget and no close to call twice.

A design note. I did not write a warning for a forgotten close. I removed the statement from the language, for the reason break is gone: a rule that depends on every author remembering it on the failure path is broken on the failure path, which is the path tested least. Here nobody has to remember. The compiler generates the close, on every path, at the end of the try or the holder that owns the handle. - Steve

Where this stops

The compiler checks what it can see in the block you are in: that a try has one catch with one parameter, that a closeable is owned by a try or a holder, that nothing closes a resource by hand, and that a resource is not given to something that keeps it. It does not check that an exception is caught anywhere. An uncaught exception ends the program with its exit code, as FatalExit showed, and nothing in a signature warned the caller it might. Which failure is a Result and which a throw is a design decision, and it stays one.

  1. handle is a synonym for catch; the compiler treats the two alike. ↩

  2. The closeable that Volume 1 introduced was a file, in Programs, Input and Output: try -> input <- file.input() hands the try a reader, and the try closes it. ↩