June 12, 2025 - Tagged as: en, plt, fir.
In the previous two posts (1, 2) we looked at how Fir utilizes variant types for exceptions tracked at function types, aka. checked exceptions.
As I wrote more and more Fir, it quickly became obvious that the current variant type design is just too verbose and difficult to use.
To see the problems, consider a JSON parsing library. This library may throw a parse error when the input is not valid. Before the recent changes, the parsing function would look like this:
parse(input: Str) Json / [ParseError, ..exn]:
...
# When things go wrong:
throw(~ParseError)
...
(As a reminder: [ParseError, ..exn]
part is the variant type for the exceptions that this function throws. ParseError
is a label for the exception value, and it has no fields. ..exn
part is the row extension, allowing this function to be called in functions that throw other exceptions.)
This error type is not that useful, because the label ParseError
doesn’t contain any information like the error location.
When we start adding fields to it, things quickly get verbose:
parse(input: Str) Json / [ParseError(errorByteIdx: U32, msg: Str), ..exn]:
...
# When things go wrong:
throw(~ParseError(
errorByteIdx = ...,
msg = ...,
))
...
Now every function that propagates this error needs to include the same fields in the label.
As a second problem, suppose that there’s another library that parses YAML, which also throws an exception with the same label ParseError
. Because we can’t have the same label multiple times in a variant (as we would have no way of distinguishing them in pattern matching), we can’t call both library functions in the same function, doing that would result in a type error about duplicate labels with different fields.
For the verbosity of labels with fields: we could have type synonyms for variant alternatives, but this doesn’t solve the problem with using the same labels in different libraries.
For the label conflicts: we could manually make the labels unique, maybe by including library name in the label, like JsonParseError(...)
and YamlParseError(...)
.
This makes labels longer, and it doesn’t guarantee that conflicts won’t occur. For example, if we allow linking different versions of the same library in a program, two different versions of the library might have the same label JsonParseError
, but with different fields.
A combination of more creative features may solve the problem completely, but features add complexity to the language, even when they work well together. If possible, it would be preferable to improve the utility of existing features instead.
As a solution that uses only existing features, Fir variants now hold named types. The example above now looks like this:
type ParseError:
errorByteIdx: U32
msg: Str
parse(input: Str) Json / [ParseError, ..exn]:
...
# When things go wrong:
throw(~ParseError(
errorByteIdx = ...,
msg = ...,
))
...
(A named type in Fir is anything other than a record or variant. See this post for more details on named and anonymous types.)
From the type checker’s point of view, a variant is still a map of labels to fields, but we now implicitly use the fully qualified names of types as the labels.
So the variant above looks like this to the type checker: [Label("P.M.ParseError")(P.M.ParseError), ...exn]
, where P
is the package name and M
is the module path to the type ParseError
, and (...)
part after the label indicates a single positional field.
This solves all of the problems with labels, and has several of other advantages:
Named types are concise as we don’t have to list all of the fields every time we mention them.
Named types and their fields can be documented.
Named types can have methods.
Named types can be extended with more fields without breaking backwards compatibility. So now it’s possible to add more fields to ParseError
without breaking existing users.
A type with the same name defined in different packages or even modules can now be used in the same variant type.
(When showing a variant type to the user in an error message, we add package and module prefixes as necessary to disambiguate.)
If I import a named type Foo
as Bar
in a module, I can use Bar
in my variant types and it would be seen as Foo
elsewhere.
Named types can implement traits. This opens up possibilities for implicitly deriving traits for variant types.
One implication of using the fully qualified path of a type as the label is that we don’t allow the same type constructor applied to different types in the same variant. E.g. [Option[U32], Option[Bool]]
is not allowed.
This is the same limitation with duplicate labels in the original version, where [Label1(x: U32), Label1(y: Str)]
wasn’t allowed. I don’t think this will be an issue in practice.
Pattern matching works as before, but we now omit the labels, as they’re inferred from the types of patterns. Here’s a contrived example demonstrating the syntax:
f() / [Option[U32], ..exn]:
throw(~Option.None)
g() / [Result[Str, Bool], ..exn]:
throw(~Result.Ok(Bool.True))
main():
match try({
f()
g()
}):
Result.Ok(()): print("OK")
Result.Err(~Option.None): print("NA")
Result.Err(~Result.Ok(bool)): print("Bool: `bool`")
Result.Err(~Result.Err(str)): print("Str: `str`")
This is essentially the same as before, just with variant labels omitted.
To keep things simple, I haven’t implemented supporting literals in variant syntax yet: ~123
, ~"Hi"
, or ~'a'
doesn’t work yet. It wouldn’t be too much work to implement this, but I don’t need it right now.
In retrospect, using named types in variants is such an obvious improvement, with practically no downsides. But it took a few thousands of lines of Fir for me to realize this.
If I discover cases where explicit labels are useful, the current design is not incompatible with the old one. The type checker still uses the same variant representation, with a label and a field for each alternative (with multiple fields are represented as records). It shouldn’t be too difficult to support both named types and labels in variant types.
This new design improves error handling quite a bit, but there are still a few problems we need to solve. In a future post I’m hoping to talk about the issues with adding a type component to the function types for exceptions.