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 = ReadWriteFile.new('hello.txt').expect('failed to create the file')
}
}
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 returns a Result[ReadWriteFile, Error]
, where Error
is the std.io.Error
type:
fn pub static new(path: IntoPath) -> Result[ReadWriteFile, Error] {
# ...
}
The Result
type is a regular algebraic type, similar to Option
. To use the
underlying value, we have to pattern match against the Result
value.
Errors can be handled in a few different ways. The most verbose approach is to
pattern match using match
:
match ReadWriteFile.new('hello.txt') {
case Ok(file) -> ...
case Error(error) -> ...
}
We can also use the try
keyword to return a new Result
containing the error
value, if an error occurred:
try ReadWriteFile.new('hello.txt')
try
is only available if the surrounding method or closure's return type is a
Result
or Option
. try expr
works as follows:
- If
expr
is aResult
and the case isError
, return theError
case, otherwise unwrap theOk
. - If
expr
is anOption
and the case isNone
, return aNone
, otherwise unwrap theSome
.
For example, this:
let result: Result[Int, String] = Result.Ok(42)
try result
Is the same as this:
let result: Result[Int, String] = Result.Ok(42)
match result {
case Ok(val) -> val
case Error(err) -> return Result.Error(err)
}
We can also use methods, such as unwrap
and expect
, to get the underlying
value and panic if an error is encountered:
ReadWriteFile.new('hello.txt').unwrap # Panic with a default error message
ReadWriteFile.new('hello.txt').expect('oh no!') # Panic with the given message
In general you'll want to avoid using unwrap
and related methods in libraries,
and only use it in executables if you're certain the error won't occur or
there's no better option than terminating the program in the event of an error.
For the sake of brevity we'll use expect
in the rest of this guide.
Moving on, let's write the message to the file:
import std.fs.file.ReadWriteFile
class async Main {
fn async main {
let file = ReadWriteFile.new('hello.txt').expect('failed to create the file')
file.write_string('Hello, World!').expect('failed to write to the file')
}
}
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 = ReadWriteFile.new('hello.txt').expect('failed to create the file')
file.write_string('Hello, World!').expect('failed to write to the file')
}
}
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 = ReadWriteFile.new('hello.txt').expect('failed to create the file')
file.write_string('Hello, World!').expect('failed to write to the file')
file.seek(0).expect('failed to rewind the file cursor')
let bytes = ByteArray.new
file.read_all(bytes).expect('failed to read the file')
}
}
Here we rewind to the start using file.seek(0).expect(...)
, 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 = ReadWriteFile.new('hello.txt').expect('failed to create the file')
file.write_string('Hello, World!').expect('failed to write to the file')
file.seek(0).expect('failed to rewind the file cursor')
let bytes = ByteArray.new
file.read_all(bytes).expect('failed to read the file')
STDOUT.new.write_bytes(bytes).expect('failed to write to STDOUT')
}
}
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 = ReadWriteFile.new('hello.txt').expect('failed to create the file')
file.write_string('Hello, World!').expect('failed to write to the file')
file.seek(0).expect('failed to rewind the file cursor')
let bytes = ByteArray.new
file.read_all(bytes).expect('failed to read the file')
STDOUT.new.write_bytes(bytes).expect('failed to write to STDOUT')
let _ = remove(file.path)
}
}
Removing files is done using the method std.fs.file.remove
, which we now
import along with the ReadWriteFile
type. Because failing to remove the file
isn't a big deal, we ignore the Result
returned by it by assigning it to _
.
Tip
At the moment, the compiler doesn't enforce using a Result
when it's
returned. In the future it will be an error to ignore Result
values. To
future-proof your code, make sure to assign Result
values that can be
ignored to _
.
Producing errors
When using the Result
type for error handling, there are two ways we can
signal an error:
- Using a regular
return
:return Result.Error('oh no!')
- Using the
throw
keyword:throw 'oh no!'
Using throw x
is the same as return Result.Error(x)
, but saves you a bit of
typing.
If a method returns an Option
, the throw
keyword can't be used as the None
case of Option
doesn't wrap a value. In this case you have to use a regular
return Option.None
.
The cost of error handling
Error handling involves pattern matching, which does incur a runtime cost,
though the cost may not matter much on modern hardware with good branch
predictors. Result
types are also heap allocated at the moment, but we hope to
optimise this away in future releases.
Errors that abort execution
Inko has two types of errors: those than can be handled at runtime using try
or match
, 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).