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 reasonbreakis 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 thetryor 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.