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 readFile
s error value to parseU32
s 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.
When we use variants as exception values, we end up with a system that is
main
returns.At the core of exceptions in Fir are these three functions:
throw
, which converts a variant into an exception:
throw(exn: [..r]): {..r} a
try
, which converts exceptions into Result.Err
values:
try(cb: Fn(): {..exn} a): {..r} Result[[..exn], a]
untry
, which converts a Result.Err
value into an exception:
untry(res: Result[[..errs], a]): {..errs} a
(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.