Error Handling in Odin

In this small post we see different error handling strategies that Odin provides and compare it to Elixir.

Elixir

In Elixir we can handle errors by returning a tuple {:error, reason} and {:ok, result} if the result is ok. Also we can have exceptions (try, catch, rescue).

defmodule PositiveSum do
  def sum(a, b) when is_number(a) and is_number(b) do
     case (a + b) do
          result when result > 0 -> {:ok, result}
          result when result < 0 -> {:error, "Only positive results allowed"}
          _ -> {:error, "Zero is neither positive nor negative"}
     end
  end
end

PositiveSum.sum(1, 2)
|> IO.inspect

PositiveSum.sum(1, -4)
|> IO.inspect

PositiveSum.sum(0, 0)
|> IO.inspect

try do
  PositiveSum.sum(1, "b")
rescue
  _error -> {:error,"Params are not numbers"}
end
|> IO.inspect

Output

{:ok, 3}
{:error, "Only positive results allowed"}
{:error, "Zero is neither positive nor negative"}
{:error, "Params are not numbers"}

Odin

In Odin we can handle the errors using different strategies by returning multiple results from a procedure. Since Odin is a typed language, is a lot harder to send wrong typed params to a procedure. It won’t compile.

Strategy 1: ok booleans

The first strategy is to return a bool at the last return parameter. This strategy will only give a ok or !ok status.

sum := proc(a : int, b : int) -> (result: int, ok: bool) {
    result = a + b
    if result > 0 {
        return result, true
    }

    if result < 0 {
        return result, false
    }
    return result, false
}

fmt.println(sum(1, 2)) // 3, true
fmt.println(sum(1, -4)) // -3, false
fmt.println(sum(0, 0)) // 0, false

fmt.println(sum(0, "b")) // won't compile

But if we use the result, we would need to store it in several variables. Or we would get a compilation error similar to: Error: Assignment count mismatch '1' = '2'.

result := sum(1, -4) // Won' compile.

This means we must store the success (ok) status somewhere.

// -3, false
result, ok := sum(1, -4)
if !ok {
  fmt.printfln("%d, %s", result, "There were problems in the Sum")
}

We could ommit the variable using the special _ character (rune).

// -3, false
result, _ := sum(1, -4)
fmt.printfln("%d", result)

We can add #optional_ok tag to the procedure declaration so we can omit the final boolean.

Important

#optional_ok requires exactly 2 return params. Only accepts the last param as a boolean.

sum := proc(a : int, b : int) -> (result: int, ok: bool) #optional_ok {...}
// -3, false
result := sum(1, -4)
fmt.printfln("%d", result)

There is also the #optional_allocation_error that can be used instead of #optional_ok and its meant for procedures that could return an allocation error.

Strategy 2: Error messages

We can return the message. However there is no way to tag the declaration to be optional the same way a boolean can.

sum :: proc(a : int, b : int) -> (result: int, err: string) {
    result = a + b
    if result > 0 {
        return result, ""
    }

    if result < 0 {
        return result, "Only positive results allowed"
    }

    return result, "Zero is neither positive nor negative"
}

result, err := sum(5, -6)
if err != "" {
    fmt.printfln("%d, %s", result, err)
}

Strategy 3: Int returns

This can be used when dealing with C libraries or system processes that returns a number to indicate status. We have to combine them with arrays or enums so we can have an error message.

error_strings := [?]string{
  "ok", 
  "Only positive results allowed", 
  "Zero is neither positive nor negative"
}

sum :: proc(a : int, b : int) -> (result: int, err: int) {
    result = a + b
    if result > 0 {
        return result, 0
    }

    if result < 0 {
        return result, 1
    }

    return result, 2
}

result, status := sum(5, -6)
fmt.printfln("%d, %s", result, error_strings[status])

Strategy 4: Enum Errors

This strategy provides a little more standarization of error codes and messages, by using enum.

PositiveSumError :: enum {
    None,
    Negative_Result,
    Zero_Result,
}

positive_sum_error_message :: proc(err : PositiveSumError) -> (message: string) {
    switch err {
    case .None:
        message = ""
    case .Negative_Result:
        message = "Only positive results allowed"
    case .Zero_Result:
        message = "Zero is neither positive nor negative"
    }
    return message
}

sum :: proc(a : int, b : int) -> (result: int, err: PositiveSumError) {
    result = a + b
    if result > 0 {
        return result, .None
    }

    if result < 0 {
        return result, .Negative_Result
    }

    return result, .Zero_Result
}

result, status := sum(5, -6)

fmt.printfln("%d, %v", result, positive_sum_error_message(status))

This can also be simplified to

PositiveSumError :: enum {
    None,
    Negative_Result,
    Zero_Result,
}

// usage: error_strings[.Negative_Result]
error_strings := [PositiveSumError]string{
  .None = "ok", 
  .Negative_Result = "Only positive results allowed", 
  .Zero_Result = "Zero is neither positive nor negative"
}

You can use unions to join different enums. We can see an example in net/common.odin.

General_Error :: enum u32 {
	None = 0,
	Unable_To_Enumerate_Network_Interfaces = 1,
}

DNS_Error :: enum u32 {
	Invalid_Hostname_Error = 1,
	Invalid_Hosts_Config_Error,
	Invalid_Resolv_Config_Error,
	Connection_Error,
	Server_Error,
	System_Error,
}

Network_Error :: union #shared_nil {
	General_Error,
	DNS_Error,
    // ... //
}

Strategy 5: Struct Errors

In this strategy we use structs to save the message. Optionally we combine it with enums for easier comparison later.

PositiveSumErrorCode :: enum {
    None,
    Negative_Result,
    Zero_Result,
}

PositiveSumError :: struct {
    message: string,
    code : PositiveSumErrorCode,
}

sum :: proc(a : int, b : int) -> (result: int, err: PositiveSumError) {
    result = a + b
    if result > 0 {
        return result, PositiveSumError{code = .None}
    }

    if result < 0 {
        return result,  PositiveSumError{
            message = "Only positive results allowed",
            code = .Negative_Result,
        }
    }

    return result,  PositiveSumError{
        message = "Zero is neither positive nor negative",
        code = .Zero_Result,
    }
}

result, err := sum(5, 6)
fmt.printfln("%d, %s", result, err.message)

Strategy 6: Struct + Ok

In this strategy we combine both the struct errors and ok boolean. This is the most similar to Elixir and other programming languages that uses exceptions.

PositiveSumErrorCode :: enum {
    None,
    Negative_Result,
    Zero_Result,
}

PositiveSumError :: struct {
    message: string,
    code : PositiveSumErrorCode,
}

PositiveSumResult :: struct {
    value : int,
    error : PositiveSumError,
}

sum :: proc(a : int, b : int) -> (result: PositiveSumResult, ok : bool) #optional_ok {
    value := a + b
    if value > 0 {
        return PositiveSumResult{
            value = value, 
            error = PositiveSumError{code = .None},
        }, true
    }

    if value < 0 {
        return PositiveSumResult{
            value = value, 
            error = PositiveSumError{
                message = "Only positive results allowed",
                code = .Negative_Result,
            },
        }, false
    }

    return PositiveSumResult{
        value = value, 
        error = PositiveSumError{
            message = "Zero is neither positive nor negative",
            code = .Zero_Result,
        },
    }, false
}

result, ok := sum(5, -6)
// -1, Only positive results allowed, ok? false
fmt.printfln("%d, %s, ok? %v", result.value, result.error.message, ok)
5 Likes

Basically you can use 3 and 4 at the same time, end it would probably be preferable in most cases.

PositiveSumError :: enum {
	None,
	Negative,
	Zero,
}

@(rodata)
POS_SUM_ERROR_STR := [PositiveSumError]string {
	.None     = "",
	.Negative = "only positive results allowed",
	.Zero     = "zero is neither positive nor negative",
}

pos_sum_error_str :: proc(code: PositiveSumError) -> string {
	return POS_SUM_ERROR_STR[code]
}

If you need errors with a more context, that’s when “errors as structs” come in handy. For this case they are quite overkill, but if you do you need them, then you probably want to roll with union errors to keep the “err == nil then it’s all good” semantics. Bit more verbose, but you can do a lot more with them…

package pos_sum

import "core:fmt"
import "base:runtime"

Sum_Error_Context :: struct {
	args:     [2]int,
	location: runtime.Source_Code_Location,
}

// make shared error context distinct types, so we can match
// on them like in case of enums...
Sum_Error_Negative :: distinct Sum_Error_Context
Sum_Error_Zero     :: distinct Sum_Error_Context

Sum_Error :: union {
	Sum_Error_Negative,
	Sum_Error_Zero,
}

sum :: proc(a, b: int, loc := #caller_location) -> (result: int, err: Sum_Error) {
	result = a + b
	switch {
		case result < 0: err = Sum_Error_Negative {
			args     = {a, b},
			location = loc
		}
		case result == 0: err = Sum_Error_Zero {
			args     = {a, b},
			location = loc
		}
	}
	return
}

fmt_error :: proc(error: Sum_Error, allocator := context.allocator) -> string {
	context.allocator = allocator

	reason: string
	ctx:    Sum_Error_Context
	switch err in error {
		case nil:
			return fmt.aprint("ok")
		case Sum_Error_Negative:
			reason = "negative result"
			ctx    = cast(Sum_Error_Context)err
		case Sum_Error_Zero:
			reason = "zero result"
			ctx    = cast(Sum_Error_Context)err
		case:
			unreachable()
	}
	return fmt.aprintf("%v: %s when (a = %d, b = %d)",
		           ctx.location, reason, ctx.args[0], ctx.args[1])

}

main :: proc() {
	res, err := sum(10, -11)
	if err != nil {
		msg := fmt_error(err, context.temp_allocator)
		defer delete(msg, context.temp_allocator)
		fmt.eprintln(msg)
	}
}

Which would output:

/tmp/scratch/error.odin(60:14): negative result when (a = 10, b = -11)

Note: if the context for all error cases is the same, it could very well just be a Sum_Error :: struct {} and sum :: proc(...) -> (result: int, err: Maybe(Sum_Error))

5 Likes