Getting Started with EK9 · Chapter 11

Optional and Result

Collections named two small containers and promised to return to them. Optional and Result are how an EK9 program says two things it needs to say all the time: "there might be a value here, or there might not", and "this either worked, or it went wrong". Many languages have versions of these. What EK9 adds is that the compiler will not let you reach a value you have not checked, and will not let you quietly throw the answer away.

Optional: zero or one

An Optional holds either one value or none: the smallest collection, a box that holds at most one thing and says when it is empty.

Use it when a function might not have an answer. Here is a phone book that looks up a number by name, where sometimes the name is not in it:

#!ek9
defines module book.phonebook

  defines function
    lookupNumber() as pure
      -> name as String
      <- rtn <- Optional() of String
      book <- {"Ada": "0100 111", "Grace": "0100 222"}
      if book contains name
        rtn: Optional(book.getOrDefault(name, String()))

  defines program
    Phone()
      stdout <- Stdout()

      ada <- lookupNumber("Ada")
      if ada?
        stdout.println(`Ada: ${ada.get()}`)

      zara <- lookupNumber("Zara")
      if zara?
        stdout.println(`Zara: ${zara.get()}`)
      else
        stdout.println("No number for Zara")

It prints:

Ada: 0100 111
No number for Zara

lookupNumber returns an Optional of String. If the name is in the book, it returns Optional(theNumber), a box with a value. If not, it returns the empty Optional() of String it started with. Either way the caller gets a real answer.

Now look at how the caller reads it. To take the value out you call .get(), but only after checking that the box is not empty, with if ada?. Inside that block, and only there, ada.get() is allowed. Leave out the check and the program does not compile.

Result: this worked, or it did not

An Optional says "a value, or nothing". Often you need to say why there is no value. That is a Result: it carries either a success value or an error. Here is a division that refuses to divide by zero:

#!ek9
defines module book.divide

  defines function
    safeDivide() as pure
      ->
        a as Integer
        b as Integer
      <- rtn <- Result() of (Integer, String)

      if b == 0
        rtn: rtn.asError("cannot divide by zero")
      else
        rtn: rtn.asOk(a / b)

  defines program
    Div()
      stdout <- Stdout()

      good <- safeDivide(10, 2)
      if good.isOk()
        stdout.println(`Answer: ${good.ok()}`)

      bad <- safeDivide(10, 0)
      if bad.isError()
        stdout.println(`Cannot: ${bad.error()}`)

which prints:

Answer: 5
Cannot: cannot divide by zero

A Result of (Integer, String) holds an Integer on success or a String on failure. The return line, <- rtn <- Result() of (Integer, String), declares the return value with an empty Result, which the body then fills in; this is the short, inferred form from Functions, allowed because a new Result() of (...) is a simple starting value. safeDivide fills it with asOk(...) or asError(...). The caller asks which it got — isOk() or isError() — and only inside the matching block may it read the matching value: ok() after isOk(), error() after isError().

The two outcomes are in the type. There is no error code in a separate variable for a caller to forget, and you cannot reach either outcome without checking first.

Note. The check must match the accessor. Checking isError() and then reading ok() is a compile error. You can read the success value only on the success path, and the error value only on the error path.

The compiler is watching

All of this rests on one rule: you cannot read a value you have not checked. The compiler keeps it. Try a shortcut:

maybe <- Optional("hi")
stdout.println(maybe.get())

This does not compile (the whole program is uncheckedGet.ek9, in the error-examples folder):

EK9     : Error   : E08030: 'get' on line 9 position 27: 'maybe as Optional of String': has not been checked before access
             See: ek9 -h E08030

The compiler followed the code, found no if maybe? protecting the get(), and stopped. There is no way to force the value out unchecked; the safe path is the only path.

Two more rules come from the same thinking. You cannot throw away a Result or Optional: if a function hands one back, you must look at it (discardedResult.ek9):

EK9     : Error   : E11052: 'work' on line 13 position 6: method returns Result or Optional but result is discarded - must always be checked
             See: ek9 -h E11052

And inside a block that has checked a value, you cannot replace that value, because the check would then no longer be true (staleCheck.ek9):

maybe <- Optional("hi")
if maybe?
  maybe := Optional("bye")
  stdout.println(maybe.get())
EK9     : Error   : E08040: ':=' on line 10 position 14: 'maybe as Optional of String': Reassignment/mutation within possible 'safe method access' scope is not allowed
             See: ek9 -h E08040

Each is a small rule; together they close off a whole category of mistake.

Why it matters when the check happens

Optional and result types are common in modern languages.1 The difference is when a mistake is caught. In EK9, a value read without a check and an error left unexamined each become a program that does not build, with a message pointing at the exact spot. Catching it while you write the code costs a moment; catching it in a running program costs far more. And there is no null to fall back on (Guards and Safe Code told that story): Optional and Result are two of the tools EK9 offers in its place.

Try it yourself

  1. Add "Steve" to the phone book, look him up, and print his number inside an if ... ? check.
  2. Look up a name that is not there and handle the empty case with an else.
  3. In the division program, call safeDivide(7, 0) and print the error. Then try to read good.ok() without the if good.isOk() check, and read the compiler's error.
  4. Change the last exercise to check isError() but read ok(), and see the compiler catch the mismatch.

Recap

EK9 has two containers for uncertainty. An Optional holds a value or nothing; you read it with .get() only inside an if value? check. A Result holds a success or an error; you read ok() only after isOk(), and error() only after isError(), with the check matching the accessor. The compiler enforces all of it: unchecked access (E08030), a discarded result (E11052), and a check made stale by reassignment (E08040) are compile errors.

Next come streams: a way to run a whole collection of values through a pipeline of small steps.

  1. In Rust, an Option or Result also provides .unwrap(), which crashes at run time if the value is missing. In Go, a function returns a value and an error side by side, but nothing forces the caller to look at the error. EK9 turns both of those mistakes into compile errors. ↩