Skip to content

Error handling

Let's take a look at one of the defining features of Inko: its approach to error handling, inspired by the article "The Error Model". To explain this, we'll transform our "Hello, World!" program from the "Hello, World!" guide into a program that writes the message to a file, reads it back, then writes it to STDOUT.

"Hello, World!" using files

We'll start with the following code:

import std::fs::file::ReadWriteFile

class async Main {
  fn async main {

  }
}

Instead of importing STDOUT we import ReadWriteFile. This is a type used for both reading and writing from and to a file. The std::fs::file module also provides a type for just reading files (ReadOnlyFile), and a type for just writing files (WriteOnlyFile). In our case we need both, hence the use of ReadWriteFile.

Next we'll need to create our file:

import std::fs::file::ReadWriteFile

class async Main {
  fn async main {
    let file = try! ReadWriteFile.new('hello.txt')
  }
}

ReadWriteFile.new('hello.txt') creates a new instance of the ReadWriteFile type and tells it to try and open the file hello.txt, creating it if it doesn't exist.

Creating a file may fail, such as when you don't have permissions to do so. As such the new method is annotated to signal that it may throw an error. The signature of the method is as follows:

fn pub static new(path: IntoPath) !! Error -> Self {
  # ...
}

For now we can ignore everything except the following:

!! Error

Within a method signature, the syntax !! TypeName indicates the method may throw a value of type TypeName. In our case the type is called Error. A method may only specify a single type, simplifying the error handling process. If a method specifies a throw type, we must handle the error when calling the method, and not doing so results in a compile-time error. If a method specifies a throw type, it must at some point throw a value of said type using the throw or try keyword. Again it's a compile-time error to not do so.

These rules mean that a method can never lie about throwing or not, and every error is guaranteed to be handled in some way. Due to the syntax used for error handling it's also clear that a call may throw, meaning you don't have to look at a method's definition just to figure that out.

Error handling is done using the try and try! keywords. try defaults to throwing the error again:

let file = try ReadWriteFile.new('hello.txt')

This is subject to the same requirements for error handling as listed above. This means you can't just use try expression in a method without annotating the method accordingly.

Explicitly handling the error is done using the syntax try EXPR else ELSE. For example, if we just want to return from the surrounding method we can do so as follows:

let file = try ReadWriteFile.new('hello.txt') else return

You can also use curly braces for both the try and else bodies:

let file = try {
  ReadWriteFile.new('hello.txt')
} else {
  return
}

If we want to do something with the error, we can specify a variable to bind it to:

let file = try ReadWriteFile.new('hello.txt') else (error) {
  return
}

If there's no sensible way of handling the error at runtime, we can decide to abort the program with an error message. This is known as a panic, and we can do this by using try! (that's try but with an exclamation mark at the end).

For this guide we just want to abort if we can't create the file, hence the use of the try! keyword.

Moving on, let's write the message to the file:

import std::fs::file::ReadWriteFile

class async Main {
  fn async main {
    let file = try! ReadWriteFile.new('hello.txt')

    try! file.write_string('Hello, World!')
  }
}

Again the operation can fail, such as when the file is removed after creating it, and so again we must handle any errors. And again, for the sake of simplicity, we'll just abort in the event we encounter an error.

If you now save the above code and run it, you'll end up with a file called hello.txt in the current working directory, containing the text "Hello, World!".

Let's combine this with writing the message back to STDOUT. For this we'll need to import STDOUT again:

import std::fs::file::ReadWriteFile
import std::stdio::STDOUT

class async Main {
  fn async main {
    let file = try! ReadWriteFile.new('hello.txt')

    try! file.write_string('Hello, World!')
  }
}

Now we'll need to read the contents back from the file. First we must rewind it, as reading continues where the last write (or read) ended; then we must read the contents into a ByteArray:

import std::fs::file::ReadWriteFile
import std::stdio::STDOUT

class async Main {
  fn async main {
    let file = try! ReadWriteFile.new('hello.txt')

    try! file.write_string('Hello, World!')
    try! file.seek(0)

    let bytes = ByteArray.new

    try! file.read_all(bytes)
  }
}

Here we rewind to the start using try! file.seek(0), aborting if we encounter an error. After that we read the entire file into a ByteArray. As the name suggests, ByteArray is a type that stores bytes. Since files can contain virtually anything, reads operate on byte arrays instead of using strings.

To write the bytes back to STDOUT, we can use the write_bytes method:

import std::fs::file::ReadWriteFile
import std::stdio::STDOUT

class async Main {
  fn async main {
    let file = try! ReadWriteFile.new('hello.txt')

    try! file.write_string('Hello, World!')
    try! file.seek(0)

    let bytes = ByteArray.new

    try! file.read_all(bytes)

    STDOUT.new.write_bytes(bytes)
  }
}

At this point you may have noticed we don't perform any error handling when writing to STDOUT, and you're correct: the STDOUT and STDERR types are implemented such that they ignore any errors when writing. This is done as there are few (if any) cases where you don't want to just ignore the error and move on.

We now have a little program that writes "Hello, World!" to a file, reads it back, then writes it to STDOUT. But what's missing is removing the file once we're done. And so for our next trick we'll make hello.txt disappear:

import std::fs::file::(ReadWriteFile, remove)
import std::stdio::STDOUT

class async Main {
  fn async main {
    let file = try! ReadWriteFile.new('hello.txt')

    try! file.write_string('Hello, World!')
    try! file.seek(0)

    let bytes = ByteArray.new

    try! file.read_all(bytes)

    STDOUT.new.write_bytes(bytes)

    try remove(file.path) else nil
  }
}

Removing files is done using the method std::fs::file.remove, which we now import along with the ReadWriteFile type. We then remove the file as follows:

try remove(file.path) else nil

This tries to remove the file, and does nothing in the event of an error. In this case that's totally fine, as not being able to remove the file isn't a problem.

Let's say that instead of aborting with a panic, we want to write a custom message to STDERR and quit the program. We'd end up with something like this:

import std::fs::file::(ReadWriteFile, remove)
import std::stdio::(STDERR, STDOUT)

class async Main {
  fn async main {
    let stderr = STDERR.new
    let file = try ReadWriteFile.new('hello.txt') else {
      stderr.print('Failed to open hello.txt')
      return
    }

    try file.write_string('Hello, World!') else {
      stderr.print('Failed to write to hello.txt')
      return
    }

    try file.seek(0) else {
      stderr.print('Failed to seek to the start of hello.txt')
      return
    }

    let bytes = ByteArray.new

    try file.read_all(bytes) else {
      stderr.print('Failed to read from hello.txt')
      return
    }

    STDOUT.new.write_bytes(bytes)

    try remove(file.path) else nil
  }
}

If we also want to display the original IO error message, we'd end up with something like this instead:

import std::fs::file::(ReadWriteFile, remove)
import std::stdio::(STDERR, STDOUT)

class async Main {
  fn async main {
    let stderr = STDERR.new
    let file = try ReadWriteFile.new('hello.txt') else (error) {
      stderr.print("Failed to open hello.txt: {error}")
      return
    }

    try file.write_string('Hello, World!') else (error) {
      stderr.print("Failed to write to hello.txt: {error}")
      return
    }

    try file.seek(0) else (error) {
      stderr.print("Failed to seek to the start of hello.txt: {error}")
      return
    }

    let bytes = ByteArray.new

    try file.read_all(bytes) else (error) {
      stderr.print("Failed to read from hello.txt: {error}")
      return
    }

    STDOUT.new.write_bytes(bytes)

    try remove(file.path) else nil
  }
}

The cost of error handling

Error handling in Inko is cheap: the cost of a throw is the same as a return, while a try consists of checking a flag and a branch. Inko doesn't automatically attach a stack trace to every error, meaning the cost of creating an error value is the same as creating any other value. Inko also doesn't implicitly unwind the stack when encountering an error.

Errors that abort execution

Inko has two types of errors: those than can be handled at runtime using try or try!, and critical errors that abort the program. Such an error is called a "panic", and is used for errors that shouldn't be handled by the developer.

An example of a panic is when you divide by zero, or when accessing an out of bounds index in an array. Both cases are the result of incorrect code, and as such all we can do is abort.

As a rule of thumb, panics should only be used when they can be triggered as the result of incorrect code, or if there's nothing you can do other than to abort (e.g. when your program requires a file to exist, but the file is missing).