osa1 github about atom

Changes to variants in Fir

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:

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.