osa1 github about atom

Error handling in Fir

January 18, 2025 - Tagged as: en, plt, fir.

A while ago I came up with an “error handling expressiveness benchmark”, some common error handling cases that I want to support in Fir.

After 7 months of pondering and hacking, I think I designed a system that meets all of the requirements. Error handling in Fir is safe, expressive, and convenient to use.

Here are some examples of what we can do in Fir today:

(Don’t pay too much attention to type syntax for now. Fir is still a prototype, the syntax will be improved.)

When we have multiple ways to fail, we don’t have to introduce a sum type with all the possible ways that we can fail, we can use variants:

parseU32(s: Str): Result[[InvalidDigit, Overflow, EmptyInput, ..r], U32]
    if s.len() == 0:
        return Result.Err(~EmptyInput)

    let result: U32 = 0

    for c in s.chars():
        if c < '0' || c > '9':
            return Result.Err(~InvalidDigit)

        let digit = c.asU32() - '0'.asU32()

        result = match checkedMul(result, 10):
            Option.None: return Result.Err(~Overflow)
            Option.Some(newResult): newResult

        result = match checkedAdd(result, digit):
            Option.None: return Result.Err(~Overflow)
            Option.Some(newResult): newResult

    Result.Ok(result)

An advantage of variants is, in pattern matching, we “refine” types of binders to drop handled variants from the type. This allows handling some of the errors and returning the rest to the caller:

defaultEmptyInput(res: Result[[EmptyInput, ..r], U32]): Result[[..r], U32]
    match res:
        Result.Err(~EmptyInput): Result.Ok(0u32)
        Result.Err(other): Result.Err(other)
        Result.Ok(val): Result.Ok(val)

Here EmptyInput is removed from the error value type in the return type. The caller does not need to handle EmptyInput.

(We don’t refine types of variants nested in other types for now, so the last two branches cannot be replaced with other: other for now.)

Another advantage is that they allow composing error returning functions that return different error types:

(Fir supports variant constructors with fields, but to keep things simple we don’t use them in this post.)

readFile(s: Str): Result[[IoError, ..r], Str]
    # We don't have the standard library support for file IO yet, just return
    # an error for now.
    Result.Err(~IoError)

parseU32FromFile(filePath: Str): Result[[InvalidDigit, Overflow, EmptyInput, IoError, ..r], U32]
    let fileContents = match readFile(filePath):
        Result.Err(err): return Result.Err(err)
        Result.Ok(contents): contents

    parseU32(fileContents)

In the early return I don’t have to manually convert readFiles error value to parseU32s error value to make the types align.

Variants work nicely with higher-order functions as well. Here’s a function that parses a vector of strings, returning any errors to the caller:

parseWith(vec: Vec[Str], parseFn: Fn(Str): Result[errs, a]): Result[errs, Vec[a]]
    let ret = Vec.withCapacity(vec.len())

    for s in vec.iter():
        match parseFn(s):
            Result.Err(err): return Result.Err(err)
            Result.Ok(val): ret.push(val)

    Result.Ok(ret)

If I have a function argument that returns more errors than my callback, I can still call it without any adjustments:

parseWith2(vec: Vec[Str], parseFn: Fn(Str): Result[[OtherError, ..r], a]): Result[[..r], Vec[a]]
    let ret = Vec.withCapacity(vec.len())

    for s in vec.iter():
        match parseFn(s):
            Result.Err(~OtherError): continue
            Result.Err(err): return Result.Err(err)
            Result.Ok(val): ret.push(val)

    Result.Ok(ret)

parseWith2(vec, parseU32) type checks even though parseU32 doesn’t return OtherError.

Similarly, if I have a function that handles more cases, I can pass it as a function that handles less:

handleSomeErrs(error: [Overflow, OtherError]): U32 = 0

parseWithErrorHandler(
        input: Str,
        handler: Fn([Overflow, ..r1]): U3
    ): Result[[InvalidDigit, EmptyInput, ..r2], U32]
    match parseU32(input):
        Result.Err(~Overflow): Result.Ok(handler(~Overflow))
        Result.Err(other): Result.Err(other)
        Result.Ok(val): Result.Ok(val)

Here I’m able to pass handleSomeErrs to parseWithErrorHandler, even though it handles more errors than what parseWithErrorHandler argument needs.

Variants as exceptions

When we use variants as exception values, we end up with a system that is

At the core of exceptions in Fir are these three functions:

(The part in {...} is the exception variant type. The reason we use {...} instead of [...] is to be able to distinguish exception types from return types in function type signatures when one of the types are omitted. This part of the syntax may change in the future.)

Here are some of the code above, using exceptions instead of error values:

parseU32Exn(s: Str): {InvalidDigit, Overflow, EmptyInput, ..r} U32
    if s.len() == 0:
        return throw(~EmptyInput)

    let result: U32 = 0

    for c in s.chars():
        if c < '0' || c > '9':
            return throw(~InvalidDigit)

        let digit = c.asU32() - '0'.asU32()

        result = match checkedMul(result, 10):
            Option.None: throw(~Overflow)
            Option.Some(newResult): newResult

        result = match checkedAdd(result, digit):
            Option.None: throw(~Overflow)
            Option.Some(newResult): newResult

    result

readFileExn(s: Str): {IoError, ..r} Str
    # We don't have the standard library support for file IO yet, just throw
    # an error for now.
    throw(~IoError)

parseU32FromFileExn(filePath: Str): {InvalidDigit, Overflow, EmptyInput, IoError, ..r} U32
    parseU32Exn(readFileExn(filePath))

parseWithExn(vec: Vec[Str], parseFn: Fn(Str): {..errs} a): {..errs} Vec[a]
    let ret = Vec.withCapacity(vec.len())
    for s in vec.iter():
        ret.push(parseFn(s))
    ret

When a library provides one of these, it’s trivial to convert to the other:

parseU32UsingExnVersion(s: Str): Result[[InvalidDigit, Overflow, EmptyInput, ..r], U32]
    try({ parseU32Exn(s) })

parseU32UsingResultVersion(s: Str): {InvalidDigit, Overflow, EmptyInput, ..r} U32
    untry(parseU32(s))

Nice!


I’m quite excited about these results. There’s still so much to do, but I think it’s clear that this way of error handling has a lot of potential.

I’ll be working on some of the improvements I mentioned above (and I have others planned as well), and the usual stuff that every language needs (standard library, tools etc.). Depending on interest, I may also write more about variants, error handling, or anything else related to Fir.

You can try Fir online here.