Error Handling

Ki provides a powerful error management infrastructure that is similar to exceptions, but a little safer and a little more ergonomic.

This is the first time we've used the with keyword. There are many situations where the programmer only wants to execute a chunk of code if something succeeds. with provides that construct. Conceptually, with means "execute this block if this statement succeeds". Programmers familiar with exceptions likely see some similarities with try. There is more information on Ki's error handling mechanisms here.

In this case, it's not completely clear what error with is expecting. That is because with is not expecting an error, rather, it is signaling to the compiler that the following indexing operations on a dynarray are safe.

Ki programs will never experience an "index out of bounds" error, and it places most of the burden of that safety on the programmer. In this situation, the programmer can only index into a dynarray after they ensure that it has at least 4 elements. This could be read as, "when trees.ensure_capacity(4) succeeds, the following indexing operations should be safe". Without a with statement here, the compiler will return an error. Furthermore, trying to access an index outside of [0, 3] will also return an error.

There are other restrictions on indexing; for example, using variables instead of literals adds additional complications. For more information, read about Ki's safety mechanisms here.

Errors

An error is a struct:

struct error {
    var message: str
    var code: int(-1)
}
- Error information is passed through `@error` - File: `@error.file` - Line: `@error.line` - Module: `@error.module` - Function: `@error.function` - Type: `@error.type` (may be ``) - Namespace: `@error.namespace` - Number: `@error.number` - Message: `@error.message`
New errors can be created using `type`:
type PacketEmpty(error) {}

All errors have a message, but in the case of PacketEmpty, a message must be provided as there is no default. This is easy to remedy:

type PacketEmpty(error) {
    :fields {
        var message: "Packet is empty"
    }
}

Setting Error Handlers

Handling every error that occurs can be tedious, so Ki allows the programmer to set default handlers for errors.

fn dont_worry_be_happy(PacketError packet_error) {
    echo("Hey there, got a packet error: ${packet_error.message}")
}

fn read_packet(NetChannel nc) {

  @handle(PacketEmpty, fn(PacketError packet_error) {
      echo("Ugh, another packet error: ${packet_error.message}")
  })
    
  nc.load_incoming_packet()

  @handle(PacketEmpty, dont_worry_be_happy)
}

Failing

Returning errors is known as "failing" in Ki; when something experiences an error, it fails. This is more or less a synonym for "throwing an exception", and works similarly:

type NetChannel {
    fn load_incoming_packet() {
        fail(PacketEmpty())
    }

    fn get_incoming_packet() (Packet) {
        fail(PacketEmpty())
    }
}

fn dont_worry_be_happy(PacketError packet_error) {
    echo("Hey there, got a packet error: ${packet_error.message}")
}

fn main() {
    var nc: NetChannel()

    @handle(PacketError, dont_worry_be_happy)

    iferr (nc.load_incoming_packet() as e) {
        echo("Loading incoming packet failed: ${e.message}")
    }

    with (nc.get_incoming_packet() as packet) {
        echo("Got a packet: ${packet.contents}")
    }

    with (nc.get_incoming_packet() as packet) {
        echo("Got a packet: ${packet.contents}")
    }
    else (as e) {
        echo("Getting incoming packet failed: ${e.message}")
    }

Here you can see that in the case of load_incoming_packet we can use iferr because there is no return value. But we must use with on get_incoming_packet in order to bind the return value. Optionally we can handle the error in the else block, but since PacketEmpty has a default handler, it's not necessary.

The main goal of this design is to avoid:

  • Ignoring errors
  • Dangling assignments
  • Tons of error-checking code

Most exception or error designs have these problems. Ki's solution can be verbose, but it's mostly in line with how the rest of the language is structured (with blocks, scopes, etc.).