osa1.net - All posts http://osa1.net/rss.xml Ömer Sinan Ağacan [email protected] 2026-04-15T00:00:00Z Fir now compiles to C (+ extensible named types, associated types, modules, and more) http://osa1.net/posts/2026-04-15-fir-devlog.html 2026-04-15T00:00:00Z 2026-04-15T00:00:00Z One of my original goals with Fir was to bootstrap it as early as possible. I was so determined, I committed the first code for the self-hosted compiler in the 322nd commit, on 11 April 2025, after less than a year of development in the open source1, when it was barely usable. To understand how early this is, we’re currently on commit 1,052, and in my opinion it only recently became somewhat usable.

Unsurprisingly, this turned out to be a challenge, and I had to accept the fact that a compiler for a non-trivial language is a lot of work. You really need a lot of language features + a good implementation (generating fast code) for it. It’s not that it cannot be done otherwise, but the process becomes extremely slow, tedious, and boring.

Something else that became evident as I worked on the self-hosted compiler was that, even if I finish it with just the features we have, I’ll have to refactor it quite significantly as we implement the planned features, to the point where it could feel more like a rewrite than a refactoring.

Finally, I thought (perhaps mistakenly), with some of the recent developments in programming tooling and software development methods (you know what I’m talking about), with a working reference implementation + tests, bootstrapping effort could be largely automated.

So I started implementing features that I consider essential for Fir 1.0, in the reference implementation. In this post we’ll look at some of these features that were recently implemented.

Fir now compiles to C

The Fir reference implementation now compiles to C. The motivation for this development was that running the self-hosted compiler with the interpreter to compile itself was taking 8.8s, despite just parsing + name resolving. That’s already too long, and it was going to get much worse as we implement type checking, monomorphisation, and code generation.

I made a few attempts at optimizing the interpreter, but it became clear that with very little effort, compared to designing and implementing a bytecode interpreter, I could compile it to C2. Because we already had a monomorphiser, compilation to C was mostly very straightforward, and we immediately got 12x speedup: the self-hosted compiler started checking itself in 0.7s instead of 8.8s.

When working on the compiler, compiling the compiler to C, then compiling that C to native with clang, then running the executable on the compiler itself is currently at 1.7s.

This also improved the workflow in other areas: formatting the whole code base and compiling PEG files take an instant now, instead of many seconds.

This also allowed other things that made this even better in terms of return-on-investment: we got free garbage collection with the Boehm-Demers-Weiser conservative GC, and value types became trivial to implement. This will also make it easier to add C FFI in the future. (more on this below)

The interpreter still exists, mainly to keep the online interpreter running.

Value types

Fir literally started as a “high-level language with value types”, but it wasn’t entirely trivial to implement them until we had the C backend.

With the C backend, it became a matter of making it generate typed code (instead of treating all values as uint64_t* or similar), and then not mallocing value types.

Here’s an example value type, from the standard library:

# Immutable, UTF-8 encoded strings.
value type Str(
    # UTF-8 encoding of the string.
    _bytes: Array[U8],
)

Relevant struct definitions in generated C:

typedef struct Array_U8 {
    U8* data_ptr;
    uint64_t len;
} Array_U8;

typedef struct Str {
    Array_U8 _0;
} Str;

This type is then used directly (instead of as a pointer). Here’s a forward-declaration of a function from the self-hosted compiler:

// Compiler/ParseUtils.fir:32:1 parseCharLit[U32]
static Char _fun_8(Str _p0);

(The comment line here is generated by the compiler to make it easier to read the generated code.)

Associated types

This was a feature that I delayed implementing for way too longer than I should’ve, mostly because I didn’t know how to implement them and it took a while to figure it out.

Associated types in Fir are the same feature as associated types in Rust. The most common use case for them is the Iterator trait. Before associated types, Iterator in Fir looked like this: (omitting extra methods with default implementations)

trait Iterator[iter, item, exn]:
    next(self: iter) Option[item] / exn

Here’s how the CharIter’s (iterates characters of a string) Iterator implementation looked like:

impl Iterator[CharIter, Char, exn]:
    next(self: CharIter) Option[Char] / exn:

This trait definition has a problem. The type of Iterator.next is this:

[Iterator[iter, item, exn]] Fn(self: iter) Option[item] / exn

Based on this type, in a call site like charIter.next() (where charIter : CharIter), we generate the predicate Iterator[CharIter, item, exn] and the type of the call expression becomes Option[item]. (where item and exn are fresh unification variables)

If the expected type of the call expressions is not precise enough to unify that item type with a concrete type, the predicate never becomes Iterator[CharIter, Char, exn], and we can’t solve it, because there isn’t an impl for Iterator[CharIter, item, exn] (note: with generic item). We only have Iterator[CharIter, Char, exn].

This resulted in lots of type annotations in the code that uses the Iterator trait. Most importantly, it required type annotations in for loops as for loops used Iterator under the hood. For example:

for char: Char in charIter:
    print(char)

Here print is a generic function that works on any ToStr type, so without the type annotation the predicate became too generic and couldn’t be solved.

With associated types, the trait now looks like this:

trait Iterator[iter, exn]:
    type Item
    next(self: iter) Option[Item] / exn

impl Iterator[CharIter, exn]:
    type Item = Char
    next(self: CharIter) Option[Char] / exn:

With this definition, the predicate for the same call becomes Iterator[CharIter, exn] (where exn is a fresh unification variable), and that’s immediately resolved using this impl. The for loop example above now works without a type annotation.

Associated types also allowed the next feature.

It’s now possible to implement traits for record types

This was a small development in terms of code, but an important one for the language. Until this feature, we could pass records around and access fields in polymorphic contexts, but if we want to take a polymorphic record (with a row extension) and e.g. print it, there was no way.

This wasn’t too important until recently, as the main use case for records was returning multiple values. You’d then destruct/pattern match on the return values directly and use them individually. For example:

divRem(x: U32, y: U32) (div: U32, rem: U32): ...

# Users just match on the fields instead of passing the return value around
# as a record.
let (div, rem) = divRem(a, b)

However with the other developments listed below, records became much more useful, and not being able to implement traits on them became a problem.

The solution was porting PureScript’s RowToList typeclass to Fir. The idea is that we define a “magic” trait that converts record rows into heterogeneous lists:

trait RecRowToList[recRow]:
    type List
    rowToList(rec: (..recRow)) Option[List]

Here recRow is a record-row-kinded type parameter. This trait is resolved by the compiler for any valid (with right kind) type argument, and depending on the type argument the List type is also generated as an heterogeneous list. The heterogeneous list type is defined as this, in the standard library:

value type List[head, tail](
    head: head,
    tail: Option[tail],
)

In the generated List types for record rows, the head type is always a RecordField:

value type RecordField[t](
    label: Str,
    value_: t,
)

So for example, RecRowToList[row(x: U32, msg: Str)] is resolved by the type checker, and the List type is also resolved as List[RecordField[Str], List[RecordField[U32], []]]3 4.

Here’s how to implement ToStr on records using this machinery:

impl[ToStr[RecRowToList[r].List]] ToStr[(..r)]:
    toStr(self: (..r)) Str:
        match RecRowToList[r].rowToList(self):
            Option.None: "()"
            Option.Some(list): "(`list`)"


impl[ToStr[t]] ToStr[RecordField[t]]:
    toStr(self: RecordField[t]) Str:
        "`self.label` = `self.value_`"


impl[ToStr[head], ToStr[tail]] ToStr[List[head, tail]]:
    toStr(self: List[head, tail]) Str:
        match self.tail:
            Option.None: "`self.head`"
            Option.Some(t): "`self.head`, `t`"

impl ToStr[[]]:
    toStr(self: []) Str:
        panic("unreachable")

Note that the List and RecordField types are value types, so rowToList does not allocate. It just generates a different representation of the record on stack that we can recurse on.

Matching a bunch of fields at once, as a record

This was one of the very simple features that made records so much more useful.

When pattern matching fields, we can now use ..var syntax to assign unmatched fields to a variable, as a record. Here’s a simple example:

type Test(
    x: U32,
    y: U32,
    z: U32,
    msg: Str
)


main():
    let x = Test(x = 1, y = 2, z = 3, msg = "hi")
    let Test(y, ..rest) = x
    print(rest)

In the pattern, y matches the field y, rest matches the rest of the fields, as (x: U32, z: U32, msg: Str). Then, using the ToStr implementation of records as shows above, this prints (msg = "hi", x = 1, z = 3).

This is not the main use case for this feature, but just as a note, when combined with traits on records, this allows easily implementing traits by reusing records’ implementations of the traits. For example, ToStr for Test here can be implemented as:

impl ToStr[Test]:
    toStr(self: Test) Str:
        let Test(..fields) = self
        "Test`fields`"

With this implementation, the value x above now prints as Test(msg = "hi", x = 1, y = 2, z = 3). This is the same output as the derived ToStr for this type, just with the different field order. (derived impl would print fields in the source code order, so: x, y, z, msg)

Splicing records and named arguments

We can now pass records as named arguments. The feature above copies field values to records, this one copies records to named arguments for fields.

This is also straightforward and I think a simple example should suffice, using the same types as above:

main():
    let x = Test(x = 1, y = 2, z = 3, msg = "hi")
    print(x)

    let Test(y, ..rest) = x     # rest: (x: U32, z: U32, msg: Str)
    let y = Test(y = 0, ..rest)
    print(y)

# output:
# Test(msg = hi, x = 1, y = 2, z = 3)
# Test(msg = hi, x = 1, y = 0, z = 3)

Reminder: records (and variants) are value types. They’re not heap allocated. So the code above does not allocate for the rest record.

We can also make larger records from smaller ones with this feature:

main():
    let x = (x = u32(123), y = u32(456))
    let y = (msg = "hi", ..x)
    print(y)

# output: (msg = "hi", x = 123, y = 456)

Splicing two records together is currently not possible: there can be at most one ..expr in a record expression.

Extensible named types

This is a big one that I talked about in a previous post. It only became usable after the record features above, associated types, and type synonyms.

For a running example, I added a full program to the online interpreter, showing a solution to the extensible AST types problem described in the blog post. It’s extensively documented, explaining all the interesting bits, so I recommend just checking it out.

In short, we allow extending named types using record rows. Pattern matching, allocation, and everything else works the same way as records. Here’s an example:

type Foo[r](
    x: U32,
    y: U32,
    ..r
)


impl[r: Row[Rec], ToStr[RecRowToList[r].List]] ToStr[Foo[r]]:
    toStr(self: Foo[r]) Str:
        let Foo(..fields) = self
        "Foo`fields`"


main():
    let x = Foo(x = 1, y = 2, msg = "hi")
    let y = Foo(b = Bool.True, y = 10, blah = Option.Some(u32(0)), x = 11)
    print(x)
    print(y)


# output:
# Foo(msg = hi, x = 1, y = 2)
# Foo(b = Bool.True, blah = Option.Some(0), x = 11, y = 10)

Foo here is an extensible type. In the allocation sites, we allocate it with different extra fields. The inferred types here are:

  • x : Foo[row(msg: Str)]
  • y : Foo[row(b: Bool, blah: Option[U32])]

ToStr implementation is implemented using the record field matching features explained above, but it can also be derived.

This feature is used in the self-hosted compiler and the tools. The code is a bit long, but we basically use the same idea demonstrated in the online demo linked above, to add different fields to the AST nodes used by different tools. For example, here’s the AST node type for variant expressions, when compiled to C, as a part of the self-hosted compiler:

typedef struct VariantExpr_CompilerAstExts {
    Expr_CompilerAstExts* _0;
    Option_Ty _1;
} VariantExpr_CompilerAstExts;

And here’s the exact same type, but in the formatter’s compiled C code:

typedef struct VariantExpr_DefaultAstExts {
    Expr_DefaultAstExts* _0;
} VariantExpr_DefaultAstExts;

This is smaller because the formatter doesn’t have the extra field the compiler adds to the type.

Both are generated from this Fir type:

type VariantExpr[exts](
    expr: Expr[exts],
    ..AstExts[exts].InferredTyExts
)

You can see the full generic AST definitions used by the compiler and other tools here.

Because we can implement traits on records and record rows now, deriving traits also work on extensible types. In the example above, I can just add #[derive(ToDoc)] to Foo and then print it like this:

#[derive(ToDoc)]
type Foo[r](
    x: U32,
    y: U32,
    ..r
)


main():
    let x = Foo(x = 1, y = 2, msg = "hi")
    let y = Foo(b = Bool.True, y = 10, blah = Option.Some(u32(0)), x = 11)
    print(x.toDoc().render(80))
    print(y.toDoc().render(80))


# output:
# Foo(x = 1, y = 2, (msg = "hi"))
# Foo(x = 11, y = 10, (b = Bool.True, blah = Option.Some(0)))

The AST types in the compiler all derive traits this way.

Modules

Until recently, importing a module in Fir just parsed the module and copied the parsed code to the current module.

In other words, there was just one module. There were no name spaces, private definitions, selective imports, or importing with renaming.

It took quite a while to design and implement a proper module system and I actually found it quite difficult to design this, even though in the end the design was quite simple. There were two problems that made this difficult for me:

First, I wasn’t sure whether we want just namespacing (plus the usual features for selective imports, renaming, etc.) or something fancier, like first-class modules.

To figure this out I studied OCaml’s module system (and also blogged about it) and 1ML in a bit more detail, and decided that I want the modules to be type checking units (to be checked in parallel) and namespaces, instead of first-class values.

This significantly simplified the design, but the design space was still huge and there were just two constraints:

  • They shouldn’t require separate files for interfaces and implementations.
  • Recursive imports should be allowed.

So the second problem was that these requirements did not constrain the design space enough to give me a small number of options, with obvious and significant tradeoffs between them. I could probably come up with a dozen designs that would all be good enough.

In the end I had to make somewhat arbitrary decisions, based on what I needed in the past, from the other module systems that I used, and what I didn’t, and preference and taste. I updated one thing as I implemented it, and settled on this:

  • Recursive imports are allowed, and there are no interface files. Each module is implemented as one file.

  • Module paths follow directory structure on the file system. E.g. an import to Foo/Bar/Baz requires the module to be in Foo/Bar/Baz.fir in the package root.

  • A module exports every non-underscored symbol that it has direct access to. This includes names that it imports. There’s no explicit exporting.

  • Underscored symbols are only accessible with explicit module paths. There’s nothing that’s truly private. If you really want you can access all private names. This keeps the design simple by avoiding fine-grained access control with things like pub(crate) or pub(foo::bar::baz) in Rust, and conditional compilation for exposing things for testing.

  • The usual renaming features are possible: modules can be imported with different names, individual definitions can be imported with different names.

  • Module path syntax is different than associated member access syntax: module paths use / as separator, associated members use .. For example:

    • A/B in expression context means “constructor B in module A”
    • B.C in expression context means “constructor C of type B”
    • A/B.C in expression context means “constructor C in type B in module A”
  • This is currently not implemented: when a module exports something (type with constructors, function, …), everything referenced by the signature of the exported thing should also be exported.

    This is to avoid the common issues in some languages where you export a function, but not the types that it uses, and the user either has to get it from another package or can’t use your function. Or even if the function is usable (for example, the private type is in the return type position and you just call the function but don’t use the return value), users can’t add type annotations to your function.

    The principle here is that I should be able to take any expression in the program and give it a type annotation in a let statement. For trait methods, I should be able to explicitly call the methods with the type arguments. E.g. instead of foo.toStr() I should be able to do ToStr.toStr[<type of foo>](foo) so that means the trait type and all of the type arguments of the trait should be in scope and accessible.

Here’s how relevant syntax looks currently:

# Each module can have at most one `import`. Documentation comments added to
# `import` lines become documentation comment of the module. When a module
# doesn't import anything an empty `import []` can be added to document the
# module.

## This is the module documentation.

import [
    # Import everything from `Fir/Prelude`, to use directly (without module
    # prefix).
    # This is implicitly added to every module already, so not needed. On here
    # for demonstration purposes.
    Fir/Prelude,

    # This allows using symbols imported from the module with the given prefix.
    # E.g. instead of `Option.Some(123)` we do `P/Option.Some(123)`.
    Fir/Prelude as P,

    # Only imports listed things.
    Fir/Prelude/[Option, Result, min, max],

    # Only imports listed things, but with renaming.
    Fir/Prelude/[Option, Result, min as _min, max as _max],
]


main():
    # Some random combination of imported things, used in different ways.
    print(Option.Some(_min(P/max(P/u32(0), u32(1)), u32(2))))


# output: Option.Some(1)

Some other notes and clarifications on this design:

  • Re-exporting imported things can be avoided by adding underscore to the imported names. E.g. in the code examples above, _min and _max are not exported from this module, but other non-underscored imports are.

    This is not a special case for imports: underscored things are never exported. If you import something with an underscored name, it’s also not exported just like defined things.

  • Modules are only imported explicitly. There’s no re-exporting a module. So if the module above is Foo/Bar, you don’t get Foo/Bar/P when you import it.

So far I’m happy with how it looks (syntax) and how it works, but as with most things in this language, it’s open to improvements, refinements, and even backwards incompatible changes.

Smaller features: kind annotations and type synonyms

These don’t need much introduction, but I want to document why they were needed and implemented.

Type synonyms came in handy in two places:

  • With associated types, we want to refer to the associated types directly in the trait and impl bodies. For example, in the Iterator trait:

    trait Iterator[iter, exn]:
        type Item
        next(self: iter) Option[Item] / exn

    Normally the way you refer to Item here is with Iterator[iter, exn].Item. But within the trait body (and also in impls), we want to refer to them as Item directly.

  • With extensible named types, we want to be able to define generic (extensible) types in a shared library, and the in the using libraries we want to override them (shadow the original definitions) with instantiated types. For example, the AST library defines type VarExpr[exts](...). The formatter overrides it with the extension type it needs: type VarExpr = Ast/VarExpr[FormatterExts].

The second one is obviously a type synonym, but the first one also uses the same underlying code. We just make type synonyms scoped, and create new synonyms in trait and impl bodies, for the associated types.

Kind annotations became necessary as we started using row-kinded type parameters more, for the extensible named types. Currently kind inference is very simple, it only looks at the current definition. If a type parameter is used in a row extension position (i.e. ..var), its kind is inferred as Row[Rec] or Row[Var] depending on whether the extension is in a record (or fields) or variant (or constructors).

That means that in the extensible named type example above:

type Foo[r](
    x: U32,
    y: U32,
    ..r
)

Here r’s kind is inferred as Row[Rec]. But if we had another type that passed a generic r to it:

type Bar[r](foo: Foo[r])

This r’s kind was inferred as *, which is incorrect.

I don’t want to introduce module-level kind inference for various reasons, so I had to add kind annotations here. The correct definition with kind annotations is:

type Bar[r: Row[Rec]](foo: Foo[r])

Kinds follow the same syntax as types. *-kinded type parameters are just listed, without any annotations. This is useful to avoid reordering type parameters just to specify kinds of some of the types. E.g. if I have

foo(x: t, y: Bar[r]): ...

Here the inferred type parameters are [t: *, r: *], generated from the signature by left-to-right scan. When calling we can explicitly pass them as foo[type1, type2](...).

This passes wrong kinded type to Bar. To fix, we have to specify the kind of r:

foo[r: Row[Rec]](x: t, y: Bar[r]): ...

But this also reorders type parameters as [r: Row[Rec], t: *] now, as the type parameter lists are generated by a left-to-right scan of the signature.

To fix, we have to also list the type parameter t explicitly, just without a kind:

foo[t, r: Row[Rec]](x: t, y: Bar[r]): ...

This gives us the original order of the type parameters, but with the correct kinds: [t: *, r: Row[Rec]].

Next up: C header imports (C FFI)

This post is already too long so I want to keep this part short for now. With the (1) resources that I have (2) things I want to do with this language (3) what we have currently (current implementation), the shortest path to success (some kind of adoption) that I can see is by making C interop absolutely effortless.

By “effortless” I really mean it: I should be able to import a C header file in directly in Fir and just use the definitions and link the generated C with object files implementing the prototypes, and provide implementations for symbols used by other compiled C code.

Similar to the module system, this is an area I don’t have a lot of experience about. Depending on things that are our out of my control (i.e. life, responsibilities), and whether I’ll encounter fundamental issues, I suspect this will take 6-12 months to fully implement. Once done, Fir will be useful for many use cases.


  1. I started working on it earlier in 2024. Open sourced in June 2024.↩︎

  2. This was partly thanks to the GCC extension statement expressions, which allowed me to compile nested expressions directly to C without having to flatten them in an A-normal form IR or similar. The extension is also supported by clang so it didn’t make the generated C less portable.↩︎

  3. [] is the empty variant type, which doesn’t have any values.↩︎

  4. The generated list fields are sorted on field names, so msg comes before x here.↩︎

]]>
Exceptions as shared secrets, demonstrated http://osa1.net/posts/2026-03-13-exceptions-as-shared-secrets.html 2026-03-13T00:00:00Z 2026-03-13T00:00:00Z Robert Harper’s “Exceptions Are Shared Secrets” is an intriguing blog post, but it may come as a bit abstract unless you’re already familiar with the idea of accidental exception (or more generally, effect) handling, as the post has no code.

In this post I want to give an example of the problems mentioned in the original post, and say a few words on how we might go about working around or fixing these issues.

The original post makes three assumptions about what an exception is and how it should be used:

  1. An exception is just a way of passing a value from a “raiser” to a “handler”.
  2. The raiser wants to limit who can intercept and handle the value (also called a “message”) being passed.
  3. Who can intercept and handle an exception/message needs to be agreed upon via “dynamic classification”.

My understanding of “dynamic classification” is that the cooperation between a raiser and handler doesn’t happen via static types (or any other static mechanism), but by agreeing upon some dynamic features of the values being passed, in runtime (e.g. identity of the object being raised).

I found it to be very difficult to come up with a real-world example of accidental exception handling causing a real bug, and I’m not interested in hypothetical issues that much. So for a long time I thought the issue is not that “real”. It was only by coincidence that I came across an example in a discussion on stack switching in WebAssembly. Here’s my Python rewrite of the original example demonstrating the issue: (full code in a few languages at the end of the post)

We’re implementing sequences that call a callback with the elements in the sequence:

## The base class for sequences.
class Sequence:
    def for_each(self, consumer: Callable) -> None:
        raise NotImplementedError


## Counts from a given integer up. Does not stop.
class CountFrom(Sequence):
    def __init__(self, start: int):
        self.start = start

    def for_each(self, consumer: Callable) -> None:
        i = self.start
        while True:
            consumer(i)
            i += 1


## An empty sequence: does not call the callback.
class Empty(Sequence):
    def for_each(self, consumer: Callable) -> None:
        pass

We want to implement a sequence that takes two sequences and an amount as arguments. It runs the first sequence the given number of times, and then runs the second sequence in full.

A problem here is that sequences don’t support stopping after a while, they always run until completion (or forever, as in CountFrom). So how do we stop the first sequence after the given number of times?

We throw an exception in the first sequence’s callback and catch it in the call site that runs the first sequence. Here’s the full AppendAfter that implements this idea:

## The exception used to signal that the first sequence should be stopped, in
## `AppendAfter`.
class AppendAfterException(Exception):
    pass


## Runs the first sequence `amount` times, then runs the second sequence.
class AppendAfter(Sequence):
    def __init__(self, first: Sequence, amount: int, second: Sequence):
        self.first = first
        self.amount = amount
        self.second = second

    def for_each(self, consumer: Callable) -> None:
        count = self.amount

        # The callback for the first sequence. Throws an exception after being
        # called `amount` times to stop iterating the first sequence.
        def limited_consumer(element):
            nonlocal count

            # Note: weird `count` update below is intentional.
            current = count
            count -= 1
            if current == 0:
                raise AppendAfterException()
            consumer(element)

        # Run the first sequence until the callback throws, signalling to stop
        # the first sequence.
        try:
            self.first.for_each(limited_consumer)
        except AppendAfterException:
            pass

        self.second.for_each(consumer)

Here’s an example of how this works:

AppendAfter(CountFrom(0), 5, Empty()).for_each(print)

This prints: 0, 1, 2, 3, 4. (each on a new line)

But the code also has a bug. Here’s another use of it that doesn’t work as expected:

AppendAfter(
    AppendAfter(CountFrom(0), 10, CountFrom(20)),
    5,
    Empty()
).for_each(print)

This counts to 4, then jumps to 20, and then loops infinitely.

Here’s the problem: the outer AppendAfter counts to 5 in the callback it passes to the inner AppendAfter and then throws an exception to stop iteration. The inner AppendAfter passes the same callback to its first sequence, while also counting. When the outer AppendAfter’s callback throws after 5 iterations, the exception is handled by the inner AppendAfter’s exception handler. So the outer AppendAfter never sees this exception, and it keeps running its first sequence.

The outer sequence never throws an exception again, because of the way we update the count local: we update it first and then check for its previous value. This looks strange in Python, but in a language with pre/post increments/decrements it looks more plausible:

if (count-- == 0) {
  throw AppendAfterException();
}

Once this exception is caught by a wrong handler, count never becomes 0 again, so the iteration never stops.

According to the original post, an exception should be a “shared secret” between a raiser and a handler, meaning no other handler (other than the intended one) should be able to intercept and decipher it.

I’m not aware of any language that allows this kind of exceptions1. To fix this in a way that somewhat resembles the exceptions explained in the original post, we need something unique shared between a raiser and a handler, so that the handler only catches the right exceptions and propagates the rest. In our demo, this is just a matter of creating the exception value ahead of time, in a scope shared between the raiser and handler, and then handling based on object identity. Here’s the fixed AppendAfter:

class AppendAfter(Sequence):
    ...

    def for_each(self, consumer: Callable) -> None:
        count = self.amount

        # We create the exception value ahead of time. Both the raiser and
        # handler have access to it.
        sentinel = AppendAfterException()

        def limited_consumer(element):
            nonlocal count
            current = count
            count -= 1
            if current == 0:
                raise sentinel
            consumer(element)

        try:
            self.first.for_each(limited_consumer)
        except AppendAfterException as e:
            if e is not sentinel:
                raise

        self.second.for_each(consumer)

Full code:

Python implementation with the bug

from collections.abc import Callable


class AppendAfterException(Exception):
    pass


class Sequence:
    def for_each(self, consumer: Callable) -> None:
        raise NotImplementedError


class CountFrom(Sequence):
    def __init__(self, start: int):
        self.start = start

    def for_each(self, consumer: Callable) -> None:
        i = self.start
        while True:
            consumer(i)
            i += 1


class Empty(Sequence):
    def for_each(self, consumer: Callable) -> None:
        pass


class AppendAfter(Sequence):
    def __init__(self, first: Sequence, amount: int, second: Sequence):
        self.first = first
        self.amount = amount
        self.second = second

    def for_each(self, consumer: Callable) -> None:
        count = self.amount

        def limited_consumer(element):
            nonlocal count
            # Note: if you change this to only decrement count when not
            # throwing, this works as expected.
            #
            # The point is, outer AppendAfter's exception is caught by the
            # inner AppendAfter, which then leaves inner AppendAfter in an
            # invalid state where count is negative.
            current = count
            count -= 1
            if current == 0:
                raise AppendAfterException()
            consumer(element)

        try:
            self.first.for_each(limited_consumer)
        except AppendAfterException:
            pass

        self.second.for_each(consumer)


if __name__ == "__main__":
    # Works:
    AppendAfter(CountFrom(0), 5, Empty()).for_each(print)

    # Loops:
    AppendAfter(
        AppendAfter(CountFrom(0), 10, CountFrom(20)),
        5,
        Empty()
    ).for_each(print)

Python implementation with the bug fixed

from collections.abc import Callable


class AppendAfterException(Exception):
    pass


class Sequence:
    def for_each(self, consumer: Callable) -> None:
        raise NotImplementedError


class CountFrom(Sequence):
    def __init__(self, start: int):
        self.start = start

    def for_each(self, consumer: Callable) -> None:
        i = self.start
        while True:
            consumer(i)
            i += 1


class Empty(Sequence):
    def for_each(self, consumer: Callable) -> None:
        pass


class AppendAfter(Sequence):
    def __init__(self, first: Sequence, amount: int, second: Sequence):
        self.first = first
        self.amount = amount
        self.second = second

    def for_each(self, consumer: Callable) -> None:
        count = self.amount
        sentinel = AppendAfterException()

        def limited_consumer(element):
            nonlocal count
            current = count
            count -= 1
            if current == 0:
                raise sentinel
            consumer(element)

        try:
            self.first.for_each(limited_consumer)
        except AppendAfterException as e:
            if e is not sentinel:
                raise

        self.second.for_each(consumer)


if __name__ == "__main__":
    # Works:
    AppendAfter(CountFrom(0), 5, Empty()).for_each(print)

    # Also works now:
    AppendAfter(
        AppendAfter(CountFrom(0), 10, CountFrom(20)),
        5,
        Empty()
    ).for_each(print)

If you want to experiment with this in other languages:

Dart implementation

abstract class Sequence<Element> {
  void forEach(void Function(Element) consumer);
}

class CountFrom implements Sequence<int> {
  final int from;

  CountFrom(this.from);

  @override
  void forEach(void Function(int) consumer) {
    for (int i = from; ; i += 1) {
      consumer(i);
    }
  }
}

class Empty implements Sequence<int> {
  @override
  void forEach(void Function(int) consumer) {}
}

class AppendAfter<Element> implements Sequence<Element> {
  final Sequence<Element> first;
  final Sequence<Element> second;
  final int amount;

  AppendAfter(this.first, this.amount, this.second);

  @override
  void forEach(void Function(Element) consumer) {
    try {
      int count = amount;
      first.forEach((element) {
        if (count-- == 0) {
          throw AppendAfterException();
        }
        consumer(element);
      });
    } on AppendAfterException {}
    second.forEach(consumer);
  }
}

class AppendAfterException {}

void main() {
  // final simple = AppendAfter(CountFrom(0), 5, Empty());
  // simple.forEach((i) => print(i));

  final complex = AppendAfter(AppendAfter(CountFrom(0), 10, CountFrom(20)), 5, Empty());
  complex.forEach((i) => print(i));
}

Fir implementation

trait Sequence[seq, t, exn]:
    forEach(self: seq, consumer: Fn(t) / exn) / exn

# ------------------------------------------------------------------------------

type CountFrom(from: U32)

impl Sequence[CountFrom, U32, exn]:
    forEach(self: CountFrom, consumer: Fn(U32) / exn) / exn:
        let i = self.from
        loop:
            consumer(i)
            i += 1

# ------------------------------------------------------------------------------

type AppendAfter[s1, s2](
    seq1: s1,
    seq2: s2,
    amt: U32,
)

type AppendAfterStop:
    AppendAfterStop

impl[Sequence[s1, t, [AppendAfterStop, ..exn]], Sequence[s2, t, [AppendAfterStop, ..exn]]]
        Sequence[AppendAfter[s1, s2], t, [AppendAfterStop, ..exn]]:
    forEach(
            self: AppendAfter[s1, s2],
            consumer: Fn(t) / [AppendAfterStop, ..exn]
        ) / [AppendAfterStop, ..exn]:
        match try(\():
            self.seq1.forEach(\(i: t) / [AppendAfterStop, ..exn]:
                let current = self.amt
                self.amt -= 1
                if current == 0:
                    throw(~AppendAfterStop.AppendAfterStop)
                consumer(i))):
            Result.Ok(()) | Result.Err(~AppendAfterStop.AppendAfterStop):
                self.seq2.forEach(consumer)

# ------------------------------------------------------------------------------

type EmptySeq:
    EmptySeq

impl Sequence[EmptySeq, t, exn]:
    forEach(self: EmptySeq, consumer: Fn(t) / exn) / exn:
        ()

# ------------------------------------------------------------------------------

main():
    let seq =
        AppendAfter(
            seq1 = AppendAfter(seq1 = CountFrom(from = 0), seq2 = CountFrom(from = 10), amt = 5),
            seq2 = EmptySeq.EmptySeq,
            amt = 5,
        )

    try[(), [AppendAfterStop], []](
        \(): seq.forEach(\(i: U32): print(i)))

    ()

Fir implementation demonstrates that the issue is not a typing issue: it happens even with checked exceptions.

Note that in debug builds this Fir program will crash because of an underflow: the counter goes below 0 as explained above, but it’s not allowed to, as the counter type is unsigned. If you want it to loop, run in release mode.


  1. I’ve briefly looked into how exceptions work in SML as the original post mentions it a few times. In SML you can catch all exceptions, so you can intercept anything and it doesn’t fully implement Robert’s ideal exception semantics.↩︎

]]>
Containing contagious types with OCaml modules http://osa1.net/posts/2026-03-10-containing-contagious-types.html 2026-03-10T00:00:00Z 2026-03-10T00:00:00Z In the previous post we looked at a way to extend product types with new fields and sum types with new constructors, using row types, in Fir.

A problem with the approach was that it required adding type parameters to the type being extended. In the cases where the extended type is a sum type and different constructors are extended with different fields, we may even need more than one type parameter. Those type parameters can then be propagated to the use sites, and their use sites, and their use sites…

I call these kinds of type parameters “contagious”, and it’s difficult to completely avoid them in Fir. In Fir, most function types are polymorphic in the exceptions they throw. This allows things like: calling a function that doesn’t throw in throwing contexts, or calling a function that throws Error1 and another that throws Error2 from the same function, and inferring the calling function’s exception type as [Error1, Error2, ..exn]. The way we achieve this polymorphism1 is by having a type parameter representing the exceptions the function can throw2.

So I thought, maybe instead of avoiding type parameters, we should think about how we might contain, or hide them, and I started to look at existing features in other languages.

In this post we’re going to look at how OCaml modules might be used for avoiding multiple type parameters (one for each extension). It turns out OCaml modules provide a solution that’s almost right.

(Full OCaml code is at the end of this post.)

The setup

We have lots of AST types for expressions, statements, declarations, … and we want to make them extensible with new fields and new constructors. Different AST types will be extended with different fields or constructors, and even in the same AST type (e.g. Expr in the original post) we may need different types of extensions for different constructors of the type.

To keep things simple, in this post we’ll only add new fields.

As the language, we’ll use the lambda calculus, with lets. Here’s how the AST could look like in OCaml:

type expr = Var of var | App of app | Abs of abs | Let of let_
and var = { name : string }
and app = { fn : expr; arg : expr }
and abs = { param : string; body : expr }
and let_ = { bound : string; rhs : expr; body : expr }

With extensions:

type ('v, 'a, 'b, 'l) expr =
  | Var of 'v var
  | App of ('v, 'a, 'b, 'l) app
  | Abs of ('v, 'a, 'b, 'l) abs
  | Let of ('v, 'a, 'b, 'l) let_

and 'v var = { name : string; var_ext : 'v }

and ('v, 'a, 'b, 'l) app = {
  fn : ('v, 'a, 'b, 'l) expr;
  arg : ('v, 'a, 'b, 'l) expr;
  app_ext : 'a;
}

and ('v, 'a, 'b, 'l) abs = {
  param : string;
  body : ('v, 'a, 'b, 'l) expr;
  abs_ext : 'b;
}

and ('v, 'a, 'b, 'l) let_ = {
  bound : string;
  rhs : ('v, 'a, 'b, 'l) expr;
  body : ('v, 'a, 'b, 'l) expr;
  let_ext : 'l;
}

This is obviously unusable and it won’t scale with more types and constructors.

With modules, we can have a module signature with the AST types and abstract extension types, and implement it with different concrete types for the extension types.

We first define a module signature with the AST extensions:

module type AST_EXTENSIONS = sig
  type var_ext
  type app_ext
  type abs_ext
  type let_ext

  val default_var_ext : var_ext
  val default_app_ext : app_ext
  val default_abs_ext : abs_ext
  val default_let_ext : let_ext
end

AST module signature then uses the extension types:

module type AST = sig
  include AST_EXTENSIONS

  type expr = Var of var | App of app | Abs of abs | Let of let_
  and var = { name : string; var_ext : var_ext }
  and app = { fn : expr; arg : expr; app_ext : app_ext }
  and abs = { param : string; body : expr; abs_ext : abs_ext }
  and let_ = { bound : string; rhs : expr; body : expr; let_ext : let_ext }
end

We then use a functor to create new AST modules, with a given extension module:

module MakeAst (Ext : AST_EXTENSIONS) :
  AST
    with type var_ext = Ext.var_ext
     and type app_ext = Ext.app_ext
     and type abs_ext = Ext.abs_ext
     and type let_ext = Ext.let_ext = struct
  type var_ext = Ext.var_ext
  type app_ext = Ext.app_ext
  type abs_ext = Ext.abs_ext
  type let_ext = Ext.let_ext

  let default_var_ext = Ext.default_var_ext
  let default_app_ext = Ext.default_app_ext
  let default_abs_ext = Ext.default_abs_ext
  let default_let_ext = Ext.default_let_ext

  type expr = Var of var | App of app | Abs of abs | Let of let_
  and var = { name : string; var_ext : Ext.var_ext }
  and app = { fn : expr; arg : expr; app_ext : app_ext }
  and abs = { param : string; body : expr; abs_ext : abs_ext }
  and let_ = { bound : string; rhs : expr; body : expr; let_ext : let_ext }
end

In the first post we had two examples: a formatter that doesn’t need any extensions, and a type checker that needs to annotate AST nodes with inferred types. Here are the formatter’s and type checker’s AST modules:

module FmtAst = MakeAst (struct
  type var_ext = unit
  type app_ext = unit
  type abs_ext = unit
  type let_ext = unit

  let default_var_ext = ()
  let default_app_ext = ()
  let default_abs_ext = ()
  let default_let_ext = ()
end)

(* The type-checking type does not matter, just as a placeholder. *)
type ty = TyVar of string | TyArrow of ty * ty

(* Type-checking AST extensions. *)
type tc_var_ext = { inferred_type : ty option }
type tc_app_ext = { result_type : ty option }
type tc_abs_ext = { param_type : ty option }
type tc_let_ext = { bound_type : ty option }

module TcAst = MakeAst (struct
  type var_ext = tc_var_ext
  type app_ext = tc_app_ext
  type abs_ext = tc_abs_ext
  type let_ext = tc_let_ext

  let default_var_ext = { inferred_type = None }
  let default_app_ext = { result_type = None }
  let default_abs_ext = { param_type = None }
  let default_let_ext = { bound_type = None }
end)

Now, the parser needs to be able to allocate different ASTs in different use sites, and so that’s where we need one type parameter (actually, a module parameter). As far as I understand, we can’t have functions parametric over modules, so we need a functor for generating a given module’s AST in the parser, using the default_..._ext functions in the AST module:

module Parse (A : AST) = struct
  (* Parsing entry point: tokenizes and parses. *)
  let parse (input : string) : A.expr =
    ...

  (* Parse a single expression from tokens. *)
  let rec parse_expr (toks : tokens) : A.expr * tokens =
    ...
end

Similarly, any other function that’s polymorphic over AST types needs to be a part of a functor that takes an AST module as argument. As another example, here’s a function that counts the number of AST nodes:

module CountNodes (A : AST) = struct
  let rec count (e : A.expr) : int =
    match e with
    | Var _ -> 1
    | App { fn; arg; _ } -> 1 + count fn + count arg
    | Abs { body; _ } -> 1 + count body
    | Let { rhs; body; _ } -> 1 + count rhs + count body
end

The final part of the ceremony is we apply these functors to get modules that we can then use to parse, format, and count nodes:

(* Parser module for the formatter. *)
module FmtParse = Parse (FmtAst)

(* Parser module for the type checker. *)
module TcParse = Parse (TcAst)

(* Node counter on the formatter's AST. *)
module CountFmt = CountNodes (FmtAst)

(* Node counter on the type checker's AST. *)
module CountTc = CountNodes (TcAst)

Type checker and formatter then refer to these modules:

let rec check_expr (e : TcAst.expr) : ty = ...
let rec format_expr (e : FmtAst.expr) : string = ...

The good

I can easily add per-AST functions, constants, or types and my parser or type checker code doesn’t become any worse. They always refer to the AST-specific things directly, and type signatures within the parser and type checker modules don’t get more complicated as we add more extensions.

The bad

The entire AST type definitions need to be duplicated in the AST signature and MakeAst functor. Just this alone renders this feature useless for our purposes, as in any real programming language there will be a lot of AST types, and each type will be quite large too (with many fields and constructors).

There’s also a smaller-scale duplication in these lines:

module MakeAst (Ext : AST_EXTENSIONS) :
  AST
    with type var_ext = Ext.var_ext
     and type app_ext = Ext.app_ext
     and type abs_ext = Ext.abs_ext
     and type let_ext = Ext.let_ext = struct
  type var_ext = Ext.var_ext
  type app_ext = Ext.app_ext
  type abs_ext = Ext.abs_ext
  type let_ext = Ext.let_ext
  ...
end

My understanding is that the types in the struct ... end part are abstract, i.e. not visible outside of the module (similar to existentials), and the : AST with type ... part specifies the returned module signature, i.e. the public interface. They need to be in sync, but they also need to be specified separately.

The only solution I can think of to these duplications is generating code, but if I’m OK with generating code, that opens up a lot of possibilities, and I don’t need functors anymore. I could even generate the full modules with all the AST types and everything else directly, without using functors.

So in short, OCaml modules helps quite a bit, but they’re held back by the issues with code duplication.


Full code (tested with OCaml 5.3.0)

(* Tested with OCaml 5.3.0. *)

module type AST_EXTENSIONS = sig
  type var_ext
  type app_ext
  type abs_ext
  type let_ext

  val default_var_ext : var_ext
  val default_app_ext : app_ext
  val default_abs_ext : abs_ext
  val default_let_ext : let_ext
end

module type AST = sig
  include AST_EXTENSIONS

  type expr = Var of var | App of app | Abs of abs | Let of let_
  and var = { name : string; var_ext : var_ext }
  and app = { fn : expr; arg : expr; app_ext : app_ext }
  and abs = { param : string; body : expr; abs_ext : abs_ext }
  and let_ = { bound : string; rhs : expr; body : expr; let_ext : let_ext }
end

module MakeAst (Ext : AST_EXTENSIONS) :
  AST
    with type var_ext = Ext.var_ext
     and type app_ext = Ext.app_ext
     and type abs_ext = Ext.abs_ext
     and type let_ext = Ext.let_ext = struct
  type var_ext = Ext.var_ext
  type app_ext = Ext.app_ext
  type abs_ext = Ext.abs_ext
  type let_ext = Ext.let_ext

  let default_var_ext = Ext.default_var_ext
  let default_app_ext = Ext.default_app_ext
  let default_abs_ext = Ext.default_abs_ext
  let default_let_ext = Ext.default_let_ext

  type expr = Var of var | App of app | Abs of abs | Let of let_
  and var = { name : string; var_ext : Ext.var_ext }
  and app = { fn : expr; arg : expr; app_ext : app_ext }
  and abs = { param : string; body : expr; abs_ext : abs_ext }
  and let_ = { bound : string; rhs : expr; body : expr; let_ext : let_ext }
end

(* --------------------------------------------------------
   A simple recursive-descent parser, generic over any AST.

   Grammar:
     expr   ::= 'let' IDENT '=' expr 'in' expr
              | '\' IDENT '.' expr
              | app
     app    ::= atom+
     atom   ::= IDENT | '(' expr ')'
   -------------------------------------------------------- *)
module Parse (A : AST) = struct
  type tokens = string list

  (* parse_expr: top-level, handles let/lambda/application.
     Lambda and let bodies extend as far right as possible
     (i.e. parse_expr), so nested constructs work without parens:
       let f = \x. \y. x in ...
       \x. \y. x y
     parse_app_args stops at 'in', ')', and non-atom tokens,
     so 'in' correctly terminates a let-RHS that is an application. *)
  let rec parse_expr (toks : tokens) : A.expr * tokens =
    match toks with
    | "let" :: name :: "=" :: rest -> (
        let rhs, rest = parse_expr rest in
        match rest with
        | "in" :: rest ->
            let body, rest = parse_expr rest in
            ( A.Let { bound = name; rhs; body; let_ext = A.default_let_ext },
              rest )
        | _ -> failwith "expected 'in'")
    | "\\" :: param :: "." :: rest ->
        let body, rest = parse_expr rest in
        (A.Abs { param; body; abs_ext = A.default_abs_ext }, rest)
    | _ -> parse_app toks

  and parse_app (toks : tokens) : A.expr * tokens =
    let head, rest = parse_atom toks in
    parse_app_args head rest

  and parse_app_args (fn : A.expr) (toks : tokens) : A.expr * tokens =
    match toks with
    | [] | ")" :: _ | "in" :: _ -> (fn, toks)
    | _ -> (
        match parse_atom_opt toks with
        | Some (arg, rest) ->
            let node = A.App { fn; arg; app_ext = A.default_app_ext } in
            parse_app_args node rest
        | None -> (fn, toks))

  and parse_atom (toks : tokens) : A.expr * tokens =
    match parse_atom_opt toks with
    | Some r -> r
    | None ->
        let tok = match toks with t :: _ -> t | [] -> "EOF" in
        failwith (Printf.sprintf "expected atom, got '%s'" tok)

  and parse_atom_opt (toks : tokens) : (A.expr * tokens) option =
    match toks with
    | "(" :: rest -> (
        let e, rest = parse_expr rest in
        match rest with
        | ")" :: rest -> Some (e, rest)
        | _ -> failwith "expected ')'")
    | tok :: rest
      when tok <> "let" && tok <> "\\" && tok <> "in" && tok <> "="
           && tok <> "." && tok <> "(" && tok <> ")" ->
        Some (A.Var { name = tok; var_ext = A.default_var_ext }, rest)
    | _ -> None

  let parse (input : string) : A.expr =
    (* Tokenize: split on whitespace, treat parens as separate tokens *)
    let buf = Buffer.create (String.length input) in
    String.iter
      (fun c ->
        match c with
        | '(' | ')' | '.' | '\\' ->
            Buffer.add_char buf ' ';
            Buffer.add_char buf c;
            Buffer.add_char buf ' '
        | _ -> Buffer.add_char buf c)
      input;
    let s = Buffer.contents buf in
    let toks = String.split_on_char ' ' s |> List.filter (fun s -> s <> "") in
    let expr, rest = parse_expr toks in
    if rest <> [] then
      failwith (Printf.sprintf "unexpected token '%s'" (List.hd rest));
    expr
end

(* --------------------------------------------------------
   Formatter — all extensions are unit.
   -------------------------------------------------------- *)
module FmtAst = MakeAst (struct
  type var_ext = unit
  type app_ext = unit
  type abs_ext = unit
  type let_ext = unit

  let default_var_ext = ()
  let default_app_ext = ()
  let default_abs_ext = ()
  let default_let_ext = ()
end)

module FmtParse = Parse (FmtAst)

let rec format_expr (e : FmtAst.expr) : string =
  match e with
  | Var { name; _ } -> name
  | App { fn; arg; _ } ->
      Printf.sprintf "(%s %s)" (format_expr fn) (format_arg arg)
  | Abs { param; body; _ } ->
      Printf.sprintf "(\\%s. %s)" param (format_expr body)
  | Let { bound; rhs; body; _ } ->
      Printf.sprintf "(let %s = %s in %s)" bound (format_expr rhs)
        (format_expr body)

and format_arg (e : FmtAst.expr) : string =
  match e with
  | Var { name; _ } -> name
  | _ -> Printf.sprintf "(%s)" (format_expr e)

(* --------------------------------------------------------
   Type checker — extensions carry inferred types.
   -------------------------------------------------------- *)
type ty = TyVar of string | TyArrow of ty * ty
type tc_var_ext = { inferred_type : ty option }
type tc_app_ext = { result_type : ty option }
type tc_abs_ext = { param_type : ty option }
type tc_let_ext = { bound_type : ty option }

module TcAst = MakeAst (struct
  type var_ext = tc_var_ext
  type app_ext = tc_app_ext
  type abs_ext = tc_abs_ext
  type let_ext = tc_let_ext

  let default_var_ext = { inferred_type = None }
  let default_app_ext = { result_type = None }
  let default_abs_ext = { param_type = None }
  let default_let_ext = { bound_type = None }
end)

module TcParse = Parse (TcAst)

let rec format_ty (t : ty) : string =
  match t with
  | TyVar s -> s
  | TyArrow ((TyArrow _ as a), b) ->
      Printf.sprintf "(%s) -> %s" (format_ty a) (format_ty b)
  | TyArrow (a, b) -> Printf.sprintf "%s -> %s" (format_ty a) (format_ty b)

(* Placeholder: just read off the extension annotation if present. *)
let rec check_expr (e : TcAst.expr) : ty =
  match e with
  | Var { var_ext = { inferred_type = Some t }; _ } -> t
  | Var { name; _ } -> TyVar name
  | App { app_ext = { result_type = Some t }; _ } -> t
  | App { fn; _ } -> (
      match check_expr fn with TyArrow (_, ret) -> ret | t -> t)
  | Abs { param; body; abs_ext = { param_type }; _ } ->
      let p = match param_type with Some t -> t | None -> TyVar param in
      TyArrow (p, check_expr body)
  | Let { body; _ } -> check_expr body

(* --------------------------------------------------------
   Generic node counter — works on any AST.
   -------------------------------------------------------- *)
module CountNodes (A : AST) = struct
  let rec count (e : A.expr) : int =
    match e with
    | Var _ -> 1
    | App { fn; arg; _ } -> 1 + count fn + count arg
    | Abs { body; _ } -> 1 + count body
    | Let { rhs; body; _ } -> 1 + count rhs + count body
end

module CountFmt = CountNodes (FmtAst)
module CountTc = CountNodes (TcAst)

(* --------------------------------------------------------
   Demo: parse the same source in both worlds.
   -------------------------------------------------------- *)
let source = {|let id = \x. x in id 42|}

let () =
  (* Formatter world — parse and pretty-print *)
  let prog = FmtParse.parse source in
  Printf.printf "formatted: %s\n" (format_expr prog);
  Printf.printf "node count: %d\n" (CountFmt.count prog);

  (* Type checker world — parse (extensions default to None),
     then check with the placeholder checker *)
  let tc_prog = TcParse.parse source in
  Printf.printf "inferred type: %s\n" (format_ty (check_expr tc_prog));
  Printf.printf "node count: %d\n" (CountTc.count tc_prog)

  1. Actually, any kind of polymorphism. Fir currently doesn’t have trait objects and the only way to have polymorphism is by using type parameters, potentially with qualifications.↩︎

  2. This is a little bit simplified, see this post for more details and examples.↩︎

]]>
Extensible named types in Fir http://osa1.net/posts/2026-03-07-extensible-named-types-fir.html 2026-03-07T00:00:00Z 2026-03-07T00:00:00Z The front-end AST types are one of the most important types in a language implementation, and if we get them wrong nothing will be right in the rest of the implementation.

These types should be cheap to allocate and efficient to use, but also extensible, as different tools will use them differently. A type checker may want to add inferred types to expressions, but for a formatter, those inferred type fields would be a waste of memory.

One approach to this problem is to have a parser that generates parse events instead of an AST or CST, and let the tools have their own ASTs. I explored this in a previous blog post.

This approach works fine when the language is small, but for a programming language that’s never the case. Fir is currently quite simple, yet it has 28 types of expressions. Most production languages have many more.

So I’ve been thinking about making Fir’s AST types extensible with new fields in the self-hosted compiler. This AST will be used by many of the tools listed here, and more. The parser and the AST types will be published as libraries.

There are a few common ways to add new fields to an existing type:

  • With subtyping of nominal types (common in OOP languages), we can create a subtype with extra fields.

  • In languages where objects have identities (again, common in OOP languages), we can use an identity map to map objects to extra information.

  • If the objects don’t have identities, we can manually generate unique identities for objects that we want to attach extra information to, and then use a map, like in the previous option.

(3) can be done in Fir, and it has a few advantages compared to extending existing types: 1

  • The maps we use to attach extra information to AST nodes can be deallocated separately from the AST types. So if we have a long computation where we need some information in some of the steps but not later, we can allocate the maps and the deallocate while keeping the AST nodes alive.

  • We can create differently typed identities for different AST types, and generate the identities as consecutive numbers. Then use arrays instead of hash maps to map nodes to things.

  • Unlike built-in identities, we can choose the identity size (e.g. 32-bit numbers instead of 64-bit), and embed information about the values in the identities.

I think this is probably the way to go in Fir’s self-hosted compiler, at least in the short term.

However while thinking about this I also found another way to extend types with more information, with row types.

Row types in Fir today

Row types are mainly used for variants, which are the types that make exception handling in Fir safe, expressive, and convenient to use.

A variant is just a set of types, e.g. [U32, Str] is a variant type with U32 (32-bit unsigned integer) and Str (immutable, UTF-8 encoded unicode strings). Values of this type can be U32s or Strs.

The type [U32, Str, ..r] is the same as before, but it can have more types in it. When pattern matching a value of this type, we have to have a catch-all case handling the ..r part, which represents extra types that the value may have.

To construct a variant value we just add a ~ prefix, e.g. ~123 gets the type [U32, ..r] (with a fresh r).

A crucial feature of variants in Fir is that they allow type refinement when pattern matching. If I have a variant value with type [Bool, Str, ..r], and handle the Bools in a pattern match and bind the rest to a variable, the variable gets a refined type:

handleBools(arg: [Bool, Str, ..r]) [Str, ..r]:
    match arg:
        ~Bool.True: ~"True"
        ~Bool.False: ~"False"
        other: other

Here the type of other is refined as [Str, ..r], because the previous alternative of the match handles the Bool values, so at this point we know that the value can’t be a Bool. 2

When variants are used as checked exceptions, this allows things like: catching some of the exceptions thrown by a function and propagating the rest. See the link at the beginning of this section for more examples.

Now, these row types that represent “extra stuff” can also be used in records, and Fir supports that too. For example, the function below can take any record that has at least x: U32 and y: U32 fields:

printXY(record: (x: U32, y: U32, ..r)):
    print("x = `record.x`, y = `record.y`")

main():
    printXY((x = 1, y = 2))

    # Extra fields are OK:
    printXY((x = 3, y = 4, msg = "hi"))

But I think this feature of records is not that useful. In Fir, records are also value types3, and the main use case for records is returning multiple values. And when returning multiple values that “extra fields” part of the record types is not useful. This is because we can’t return a record with the extension part (..r), unless that record is passed as an argument. Consider:

returnExtensibleRecord() (x: U32, y: U32, ..r):
    ???

There’s no non-divergent expression in the body that will make this type check.

This is different than variants, where a variant construction like ~"Hi" will have type [Str, ..r] (with fresh r). So we can have this:

returnVariant() [Str, ..r]:
    ~"Hi"

In other words, rows in variants allow us to assume that a value may have some extra values, and there are many use cases where we want to do that (again, see the blog post linked at the beginning of this section).

Rows in records are for ignoring extra fields, which is not that useful if we assume that the main use case is to return more than one value from functions.

The reason why I implemented row extensions in records is that, once I had the type checker and monomorphiser that can deal with rows, it was straightforward to apply it to records as well.

It also allowed me to experiment with extensible types a bit more, which led to…

A new use case for rows?

We can use the variant rows for extending sum types with new constructors, and record rows for extending product types with new fields. Here’s an example that works in Fir today: 4

type Foo[r](
    x: U32,
    y: U32,
    ..r
)

main():
    let foo = Foo(x = 123, y = 456, z = "hi")
    print(foo.x)
    print(foo.y)
    print(foo.z)

Foo is a named type. The r is a record row kinded type parameter, representing extra fields. The type inference infers type Foo[row(z: Str)] for the type of foo. We can access the field z just like any other field.

(The only difference between a record construction syntax and a named type constructor syntax is the missing name: (x = 123, y = 456) is a record, Foo(x = 123, y = 456) is a named type value.)

This gives us a way to extend product types. For example, in our AST, the expression node for binary operators may look like this:

type BinOpExpr[extras](
    left: Expr,
    right: Expr,
    op: BinOp,
    ..extras
)

The formatter could then use this as BinOpExpr[row()], and the type checker could add an extra field for the inferred type of the expression with BinOpExpr[row(inferredTy: Ty)].

The idea applies to the sum types the same way, however it’s currently not fully implemented in my prototype, because of syntax issues. Here’s how row extensions look like with sum types:

type Expr[extras]:
    Var(VarExpr)
    BinOp(BinOpExpr)
    ..extras

Now suppose I want to extend this type with the standard library Bool type:

value type Bool:
    False
    True

How should the extra values be constructed? The way we normally construct sum values is as <type>.<constructor>(<args>), e.g. Bool.True, Expr.BinOp(...).

But with a sum type extended with another sum type, I’m not sure what syntax to use for construction. I can see two options:

  • Expr.Bool.True: extended type, extension type, then constructor.
  • Expr.True: extended type, then constructor.

There’s also the issue of not all types having a constructor name. For example, with this syntax, we wouldn’t have a way of constructing a Str as Expr[row[Str]] as string literals are not constructed with the <constructor>(<args>) syntax.

In short, I couldn’t find a nice syntax for sum types with extensions, so they’re currently not implemented in my prototype.

Problems and features needed

This approach adds type parameters to types, and type parameters can be contagious. (propagated to the use sites, and their use sites, and theirs…)5

Consider the statement type in Fir’s AST:

type Stmt:
    Let(LetStmt)
    Assign(AssignStmt)
    Expr(Expr)
    For(ForStmt)
    While(WhileStmt)
    Loop(LoopStmt)
    Break(BreakStmt)
    Continue(ContinueStmt)

To extend this I’ll need one type parameter per extension. If I have to extend let statements and for statements with different fields, I need two:

type Stmt[letExts, forExts]:
    Let(LetStmt[letExts])
    For(ForStmt[forExts])
    ...

It’s clear that this will scale poorly.

To keep the number of type parameter in check we could use something like type families (type-level functions) to have one type per use case (e.g. type checking, formatting), and then map those to different extension types, but I’m not sure if adding type-level functions just to support this feature makes sense.

Another issue is with deriving: we will have some way of deriving trait implementations, similar to Rust6. With row extensions, we can’t use a macro with just the item AST as the input, as the macro will just see type parameters for the extensions. We have to iterate the extension fields somehow in the derived code generator, and regardless of how we iterate the row fields, the actual code generation needs to be done during monomorphisation, as that’s when we know the full type arguments.

Finally, to properly type check this we have to extend the constraint language. Consider this:

type Foo[r](
    f1: U32,
    f2: Str,
    ..r
)

Here the constructor Foo will have the type: Fn(f1: U32, f2: Str, ..r) Foo[r]7, but not all rows will be valid for r: we can’t allow overriding existing fields with different types8.

It’s easy to check the example above, but in general, these “lacks” constraints (i.e. “record row type r lacks fields f1, f2”) need to be carried over to the use sites of the type parameter to be able to type check properly. In our type Stmt[letExts, forExts]: ... above, the constraints will be coming from the LetStmt and ForStmt types, not from Stmt, and they need to be carried over to the use sites of Stmt.

Currently not having these constraints on the type parameters doesn’t cause soundness issues as the monomorphiser catches these issues, but it’s not ideal because it means that these errors wouldn’t be caught in the language server (which won’t fully compile, just type check), or when running fir --typecheck <file>. Error reporting is also not as good as error reporting in the type checker.

(The lack of “lacks” constraints is not a problem until this feature because variants can always be extended with any type (duplicate types are OK), and it’s not possible to extend records. At least currently, row types in records are only for forgetting/ignoring extra fields.)

Finally, to avoid repeatedly typing the same row type arguments in the use sites in the parser, formatter, etc. we need type synonyms. Fir currently doesn’t have type synonyms because I don’t think they’re that useful when we have value types, and I hate to deal with them in the type checker.9 In our Stmt example above, we’ll want to write:

# Extensions for type checking.
alias TcLetStmtExts = row(inferredBinderType: Option[Ty])
alias TcForStmtExts = row(inferredIteratorType: Option[Ty])
alias TcLetStmt = LetStmt[TcLetStmtExts]
alias TcForStmt = ForStmt[TcForStmtExts]
alias TcStmt = Stmt[TcLetStmtExts, TcForStmtExts]
...

# Extensions for formatting.
alias FmtLetStmtExts = row()
alias FmtForStmtExts = row()
alias FmtLetStmt = LetStmt[FmtLetStmtExts]
alias FmtForStmt = ForStmt[FmtForStmtExts]
alias FmtStmt = Stmt[FmtLetStmtExts, FmtForStmtExts]
...

And then with a feature similar to type families, we can have one type for each use site (type checker, formatter, …) and map that one type to extension types for each of the rows and reduce number of type parameters. (there will always be at least one type parameter in extended types)

Final thoughts

I’m not aware of any other languages that apply row extensions to named types, which is the reason why I wanted to write this post.

The main challenge for this feature to be useful is the deriving support. The macros will have to run during monomorphisation to make use of the extra fields and constructors. The generated code will then be type checked in a different language (monomorphic AST instead of the front-end AST), which can lead to things like: code that normally doesn’t type check, but does type check when generated in a macro, as macro expansion is type checked differently. While I can’t imagine how this could happen today, that doesn’t mean it won’t, and it’s best if we just don’t open the door to this kind of thing.


  1. See also my blog post from 2020 that touches some of the same points.↩︎

  2. Variants are value (unboxed) types, so they’re not heap allocated, and refinement just moves fields around. In general, pattern matching should never allocate, and this currently holds in Fir.↩︎

  3. In short, all anonymous types are values in Fir. For named types the user decides whether to box or not.↩︎

  4. This only works in a prototype that currently lives in the extensible_named_types branch. Online interpreter does not have this feature yet.↩︎

  5. I know I failed to articulate it at the time, but I think polymorphism without requiring type parameters is the main advantage of subtyping compared to parametric polymorphism, and I think it’s the killer feature of OOP (as I define in the post). I want to get back to this point in a later blog post.↩︎

  6. We already support #[derive(...)]s today, but they’re a part of the self-hosted compiler (not libraries), and I’m not sure if we want to keep them or do it another way. I needed to derive implementations quickly and didn’t have time to consider alternatives too much.↩︎

  7. Yes, I also had to add row extensions to function types for this.↩︎

  8. I think duplicating fields should be OK.↩︎

  9. We don’t want to eagerly expand type synonyms to their RHSs because then error messages refer to the RHSs rather than synonyms, and keeping type synonyms around as we type check means we have to remember to look through them in many places. It’s a minor thing but considering how useful they are (very little, at least until this feature) it just seemed like they’re not worth it.↩︎

]]>
How Fir formats comments http://osa1.net/posts/2025-09-27-fir-formatter.html 2025-09-27T00:00:00Z 2025-09-27T00:00:00Z Fir formats comments by assigning comment tokens to non-comment tokens (only conceptually, not in the implementation, see below), and generating comments when formatting the tokens that “own” them.

This keeps AST nodes small. The parser doesn’t know about comments at all, and code that doesn’t care about comments don’t allocate more or run more code for comments.


Formatting source code with comments is tricky, and common suggestions like adding comments to AST nodes or generating lossless (or concrete) syntax trees (CSTs) are not feasible in real programming languages. Consider this simple Fir function:

add(x: U32, y: U32) U32:
    ...

This simple function, without the body, has 14 places where a comment can appear:

#|1|#
#|2|# add #|3|# (
    #|4|# x #|5|# : #|6|# U32 #|7|# ,
    #|8|# y #|9|# : #|10|# U32 #|11|#
) #|12|# U32 #|13|# : #|14|#
    ...

If I were to add comment tokens to AST nodes, about 6 of these would belong to the “function declaration” AST node:

#|1|#
#|2|# add #|3|# ( #|4|# ... ) #|12|# ... : #|14|#
    ...

Because each of these is in different positions in the declaration, they would need different fields in the AST node.

If you consider that a real programming language will have hundreds of different types of expression, statement, declaration, … nodes, it becomes clear that this approach is simply not feasible.

The CST approach is not too different, it just moves the inconvenience from the tree type definitions and tree allocations to the use sites of the trees.

What Fir does is much simpler: it requires no support from the parse trees. The parser doesn’t even know about comments, and the AST users that don’t care about comments also don’t need to deal with them and don’t pay any price for them (runtime or memory).

Conceptually, we assign every comment token to a non-comment token. In the example above, comments 1, 2, and 3 belong to the identifier add. Comment 4 belongs to the token (, and so on.

When formatting, we don’t generate text directly. Instead we format the source code token by token. In the example above, we’re formatting a function definition, so we know that there will be a left paren after the function name. But we don’t generate a “(” directly after the function name. Instead we find the token for the left paren, and format it. This formatting operation also generates comments that belong to the left paren.

Assigning comment tokens to non-comment tokens: Conceptually, every token owns:

  • Comment tokens before them that are not on the same line with another non-comment token.

  • Comment tokens after them that are on the same line with the token.

In the example above, 1 and 2 belong to the identifier add because of the first rule, and 3 also belongs to the identifier because of the second rule.

This only leaves the trailing comments at the end of a file “unowned”, which we handle separately as their own thing.

Finding tokens of AST nodes: The formatter still operates on AST nodes and AST nodes typically don’t need any extra fields for their tokens.

Instead of adding tokens to AST nodes, we represent identifiers as their tokens. Because many AST nodes have identifiers, we can start with those tokens and scan backwards and forwards to find the other tokens of the AST node, with the comments that they own.

When an AST node doesn’t have any identifiers, or finding the tokens of the node from the identifiers is difficult, we add a field for its first (or last) token, and scan forwards (or backwards) from those tokens to find the other tokens.

For example, in Fir, as of today, type declarations are represented as this: (source)

## A type declaration: `type Vec[t]: ...`.
type TypeDecl(
    ## When the type is a primitive, the `prim` token.
    prim_: Option[TokenIdx],

    ## The type name. `Vec` in the example.
    name: Id,

    ## Type parameters of the type. `[t]` in the example.
    typeParams: Vec[Id],

    ## Kinds of `type_params`. Filled in by kind inference.
    typeParamKinds: Vec[Kind],

    ## Constructors of the type.
    rhs: Option[TypeDeclRhs],
)

Note that this node doesn’t have a token for the type keyword. Instead we start from name and scan backwards. The first non-trivia token that we see will be the type token. (source)

(The prim_ field could also be removed and we could scan backwards from the type token. If you’re interested in contributing, we have an issue about cleaning up redundant token fields in AST nodes, which would be a good issue for getting started.)

Generating comments with tokens: I used the word “conceptually” a few times above, because in the implementation we don’t really assign comment tokens to non-comment tokens.

Instead, the function that formats a token scans backwards and forwards to find comment tokens as described by the rules above, and generates them with the token.


Scanning backwards and forwards to find other tokens and collecting comment tokens that belong to a token being formatted are quite simple. Here are the relevant code:

  • formatToken takes a non-comment token to be formatted and formats the token with the comments that belong to the token.

  • formatToken calls findCommentBefore to find the first comment before it that needs to be formatted with it.

    Finding the comments after it is easier, so it’s done in formatToken directly.

  • nextNonTrivia and prevNonTrivia scan forwards and backwards from a given token to find the tokens of an AST node, as mentioned in the type declaration example above.

  • The trailing comments at the end of the file are not owned by any token, so they’re not formatted by default. Instead they’re handled specially by the module formatter.

Not adding tokens to the AST nodes keeps the AST nodes small (cheaper to allocate), and parser and user code simple. Use sites that don’t care about comment nodes pay no price for larger AST nodes or extra parsing code handling comments.

(There are a few open issues about Fir’s formatter, but none that are caused by the ideas explained in this post.)

]]>
Fir is getting useful http://osa1.net/posts/2025-09-04-fir-getting-useful.html 2025-09-04T00:00:00Z 2025-09-04T00:00:00Z A few months ago I implemented a PEG parser generator in Fir. It parses its own grammar and it’s also used to parse Fir.

This week I finished another sizable1 Fir project: a code formatter for Fir. It now formats most of the Fir code in the repo2.

Fir is being designed and implemented from day one with tooling, libraries, and backwards compatibility in mind. The compiler’s front-end is currently being reused by the formatter. Soon it’ll be reused by a syntax-aware search-and-replace tool (similar to sg), and by a tool that combines Fir packages into a single .fir file (for sharing repros and automated repro reduction), and much later, by the language server and other tools. You can see the list of tools I want to implement here.

By implementing the tooling along with the first version of the compiler (all in Fir), I want to make sure we have the right SDK design to support all these tools, and more. I want to publish the Fir front-end as a reusable package. This front-end should support the last N3 releases of Fir, so that you can parse (and analyze, modify, refactor, migrate, …) the last N versions of Fir with the latest version of Fir.

I still haven’t written a post explaining what kind of language I want Fir to be, because that’s still largely an open question. However there are a few things that are decided: a compiled, typed language with ADTs, with typeclasses (called traits) for compile-time polymorphism (monomorphised, with value types), and effects. I want Fir to be a high-level, but still efficient, language.

Even implementing just a compiler is a big task, and designing and implementing a whole language with all these tools can’t be done by one person. If this vision sounds interesting to you, and you clicked on a few links above and like what you see, please don’t hesitate to reach out. Each of these tools comes with their own issues and tasks, so it’s now a good time to start contributing to Fir. I already have a list of issues for the PEG generator and the formatter. There’s also all kinds of other things in the issue tracker. Depending on your experience, you can also keep yourself entertained in other ways: the interpreter is slow (a simple AST walker), the interpreter’s type checker is not in good shape etc. If you have the experience and opinions, you can also influence the language design.

My next task is, I’ll be implementing the search-and-replace tool mentioned above (I do this now mainly because I need it when working on Fir), and in parallel, designing and implementing the module system. The module system will need to be implemented in the interpreter too, because I’ll be using modules in the compiler and other tools. Depending on how much free time I’ll have, it should be at least a month of work.

I’m happy with how it’s coming along and I’m excited about Fir’s future.


  1. Formatter is currently 1,086 loc. PEG is 850 loc without the parser for parsing itself. Generated Fir for the parsing PEGs is 2,364 loc, generated from 178 loc PEG.

    It’s a bit more difficult to precisely measure the compiler’s grammar size, because it includes semantic actions, but the grammar is 888 loc and generated parser for the grammar is 5,147 loc.

    In total (including tests), we have 21,012 loc Fir today in the repo.

    All numbers excluding comments and whitespace.↩︎

  2. We don’t format tests to avoid accidentally parsing only formatted code.↩︎

  3. I’m not sure what the exact number here should be yet.↩︎

]]>
Why I'm excited about effect systems http://osa1.net/posts/2025-06-28-why-effects.html 2025-06-28T00:00:00Z 2025-06-28T00:00:00Z Imagine a programming language where you can have full control over whether and how functions, modules, or libraries interact with shared resources like the scheduler for threading, the file system and other OS-level resources like sockets and other file descriptors, timers for things like delaying the current thread for timed updates or scheduling timed callbacks, and so on.

In this language, a function (or module, library, …) needs to declare its interactions with the shared resources in its type.

When a function accesses e.g. the file system, the caller has full control over how it accesses the file system. All file system access functions can be specified (or overridden if they have a default) by the caller.

Furthermore, assume that this language can also suspend functions and resume them later, similar to async functions in many languages today, which are paused and resumed later when the value of e.g. a Future becomes available.

This language lends itself to a more composable system compared to anything that we have today. This system is composable, flexible, and testable by default.

If you think about it, it’s really strange that today we find it acceptable that I can import a library, and the library can spawn threads, use the file system, block the current thread with things like sleep or with blocking IO operations, and I have no control over it.

Most of the time, this kind of thing will be at least documented, but if I use a library that fundamentally needs these things, unless the library accounts for my use case, I may not be able to use it in my application.

For example, maybe it spawns threads but I want it to use my own thread pool where in addition to limiting number of threads, I attach priorities to threads and schedule based on priorities.

Or, maybe I have a library that builds/compiles things by reading files, processing them, and generating files. If I have control over the file system API that the library uses, it takes no effort (e.g. no planning ahead of time) to test this library using an in-memory file system, in parallel, without worrying about races and IO bottlenecks. I don’t have to consider testing scenarios in the library and structure my code accordingly.

Or, maybe I have code that polls some resources, and maybe posts periodic updates. It creates a thread that does the periodic work, and sleeps. With control over threads, schedulers, and timers, I can fast-forward in time (to the next event) in my tests without actually waiting for sleeps and any other timed events, to test my code quickly.

These are some of the things I get to do with an effect system.

What’s in an effect system?

At a high-level, an effect system has two components: (1) a type system, and (2) runtime features.

These two components are somewhat orthogonal: you can have one without the other, depending on what you want to make possible.

In the systems available today, (1) typically involves adding a type component to function types, for the effects a function can invoke.1

For example, in Koka, if you define stdin/stdout operations in an effect named console, and have a function that uses the console effects, the function’s type signature looks like this:

fun sayHi() -> console ()
  print("hi")

This type says sayHi returns unit (()) and uses the console effect.

(2) typically involves capturing the continuation of the effect invocation and passing it to a “handler”. Depending on the system, the handler can then do things (e.g. memory operations, invoking other effects) and “jump” to (or “tail call”) the continuation with the value returned by the invoked effect.

With the console effect above, a handler may just record the printed string in a data structure, which can then be used for testing. Another handler may actually write to stdout, which would then be used when you run the application.

Depending on the exact (1) and (2) features, you get to do different things. The current effect systems in various languages support different (1) and (2) features, and there are some systems that omit one of (1) or (2) entirely.

For the purposes of this blog post, we won’t consider the full spectrum of features you can have, and what those features allow.

Example: a simple grep implementation in Koka

There isn’t a language today that gives us everything we need for the use cases I describe at the beginning.

However among the languages that we have, Koka comes close, so we’ll use Koka for a simple example.

Imagine a simple “grep” command that takes a string and a list of file paths as arguments, and finds occurrences of the string in the file contents and reports them.

In Koka, the standard library definitions for these “effects” could look like this:

effect fs
  ctl read-file(path: path): string

effect console
  ctl println(s: string): ()

Using these effects, the code that reads the files and searches for the string is not different from how it would look like in any other “functional”2 language:

fun search(pattern: string, files: list<string>): <fs, console>()
  val pattern-size = pattern.count()
  files.foreach fn(file)
    val contents = read-file(file.path)
    val parts = contents.split(pattern)
    report-matches(file, pattern-size, parts)

fun report-matches(file: string, pattern-size: int, parts: list<string>): <console>()
  if parts.length == 0 then
    return ()

  println(file)

  var line := 0
  var column := 0
  parts.init.foreach fn(part)
    part.vector.foreach fn(char)
      if char == '\n' then
        line := line + 1
        column := 0
      else
        column := column + 1

    println((line + 1).show ++ ":" ++ (column + 1).show)

When calling search, I have to provide handlers for fs and console effects.

In the executable that I generate for users, I can use handlers that do actual file system operations and print to stdout:

val fs-io = handler
  ctl read-file(path: path)
    resume(read-text-file(path))

val console-terminal = handler
  ctl println(s: string)
    write-to-stdout(s)
    resume(())

In the tests, I can use a read-file handler that reads from an in-memory map, and add printed lines to a list, to compare with the expected test outputs:

struct test-case
  files: list<test-file>
  pattern: string
  expected-output: list<string>

struct test-file
  path: path
  contents: string

val test-cases: list<test-case> = [
  Test-case(
    files = [Test-file("file1".path, "test\ntest"), Test-file("file2".path, "a\n test\nb")],
    pattern = "test",
    expected-output = ["file1", "1:1", "2:1", "file2", "2:2"]
  ),
]

fun test(): <exn>()
  var printed-lines := Nil

  test-cases.foreach fn (test)
    with handler
      ctl read-file(path_: path)
        match test.files.find(fn (file) file.path.string == path_.string)
          Just(file) -> resume(file.contents)
          Nothing -> throw("file not found", ExnAssert)

    with handler
      ctl println(s: string)
        printed-lines := Cons(s, printed-lines)
        resume(())

    search(test.pattern, test.files.map(fn (file) file.path.string))

    if printed-lines.reverse != test.expected-output then
      throw("unexpected test output", ExnAssert)

You can see the full example here.

I can already do this in language X using library/framework Y?

The point with effect systems is that, you don’t get a composable and testable system when you design for it, you get it by default.

If you implement a library that uses the file system, I can run it with an in-memory file system, or intercept file accesses to prevent certain things, or log certain things, and so on, regardless of whether you designed for it or not.

The Koka code above does not demonstrate this fully, and there’s no system available today that can. I’m just using whatever is available today.

In an ideal system, you would have to go out of your way to have access to the filesystem without using an effect, rather than the other way around.

When comparing languages we never talk about what’s possible: almost everything is possible in almost every general purpose programming language.

What we’re talking about is things like: the idiomatic and performant way of doing things.

The language where what I talk about is idiomatic and performant does not exist today.

How do we know that this ideal system is possible?

We mentioned that the two components of an effect system are somewhat orthogonal. In the design that I have in mind (more on this below), without the type system part of it you still get 90% of the benefits. So let’s focus on the runtime parts.

What you need for a flexible effect system is, conceptually, a way of suspending the stack when calling an effect, passing the suspended stack (you may want to call it a “continuation”) to the handler for the effect invoked.

This kind of thing is already possible in many of the high-level languages today. If your language supports lightweight threads (green threads, fibers, etc.), coroutines, generators, or similar features where the code is suspended when it does something like await or yield, and then resumed later, you already have the runtime features for a flexible effect system.

For me, it’s about composable and testable libraries

I deliberately didn’t mention in this blog post so far that effect systems generalize features like async/await, iterators/generators, exceptions, and many other features.

The reason is because, as a user, I don’t care whether these features are implemented using an effect system under the hood, or in some other ways. For example, Dart has all of these features, but it doesn’t use an effect system to implement them. As a user, it doesn’t matter to me as long as I have the features.

Instead, what I’m more interested in as a user is: how it influences or affects library design, and what it allows me to do at a high level, in large code bases.

However it would be a shame to not mention that, yes, effect systems generalize all these features, and more. The paper “Structured Asynchrony with Algebraic Effects” shows how these features can be implemented in Koka.

To be continued

Some of the recent discussions online about effect systems left me somewhat dissatisfied, because most posts seem to focus on small-scale benefits of effect systems, and I wanted to share my incomplete (but hopefully not incoherent!) perspective on effect systems.

In the future posts I’m hoping to cover some of the open problems when designing such a system.


Thanks to Tim Whiting for reviewing a draft of this blog post.


  1. This is a somewhat rough estimate on what these effect types in function types indicate. In practice it’s more complicated than “effects the function invokes”: if you read it as that you fail to explain some of the type errors, or why some code of the code type checks. More on this (hopefully) in a future post.↩︎

  2. “Functional” in quotes because I don’t think that word means much these days. Maybe more on this later.↩︎

]]>
Changes to variants in Fir http://osa1.net/posts/2025-06-12-fir-new-variants.html 2025-06-12T00:00:00Z 2025-06-12T00:00:00Z 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.

]]>
Throwing iterators in Fir http://osa1.net/posts/2025-04-17-throwing-iterators-fir.html 2025-04-17T00:00:00Z 2025-04-17T00:00:00Z Recently I’ve been working on extending Fir’s Iterator trait to allow iterators to throw exceptions.

It took a few months of work, because we needed multiple parameter traits for it to work, which took a few months of hacking1 to implement.

Then there was a lot of bug fixing and experimentation, but it finally works, and I’m excited to share what you can do with Fir iterators today.

As usual, link to the online interpreter with all of the code in this post is at the end.

Before starting, I recommend reading the previous post. It’s quite short and it explains the basics of error handling in Fir.

Previous post did not talk about traits at all, so in short, traits in Fir is the same feature as Rust’s traits and Haskell’s typeclasses2.

The Iterator trait in Fir is also the same as the trait with the same name in Rust, and it’s used the same way, in for loops.

Here’s a simple example of what you can do with iterators:

sum(nums: Vec[U32]) U32:
    let result: U32 = 0
    for i: U32 in nums.iter():
        result += i
    result

The Vec.iter method returns an iterator that returns the next element every time its next method is called. for loop implicitly calls the next method to get the next element, until the next method returns Option.None.

Similar to Rust’s Iterator, Fir’s Iterator trait also comes with a map method that allows mapping iterated elements:

parseSum(nums: Vec[Str]) U32:
    let result: U32 = 0
    for i: U32 in nums.iter().map(parseU32):
        result += i
    result

parseU32(s: Str) U32:
    if s.len() == 0:
        panic("Empty input")

    let result: U32 = 0

    for c: Char in s.chars():
        if c < '0' || c > '9':
            panic("Invalid digit")

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

        result *= 10
        result += digit

    result

This version takes a Vec[Str] as argument, and parses the elements as integers.

The problem with this version is that it panics on unexpected cases: invalid digits and empty input, and it ignores overflows.

Until now, there wasn’t a convenient way to use the Iterator API and for loops to do this kind of thing, while also propagating exceptions to the call site of the for loop, or to the loop variable. But now we can do this: (parseU32Exn is from the previous post)

parseSum(nums: Vec[Str]) U32 / [Overflow, EmptyInput, InvalidDigit, ..errs]:
    let result: U32 = 0
    for i: U32 in nums.iter().map(parseU32Exn):
        result += i
    result

Errors that parseU32Exn can throw are now implicitly thrown from the for loop and reflected in the function’s type.

This new Iterator API is flexible enough to allow handling some (or all) of the exceptions thrown by a previous iterator. For example, here’s how we can handle InvalidDigit exceptions and yield 0 instead:

parseSumHandleInvalidDigits(nums: Vec[Str]) U32 / [Overflow, EmptyInput, ..errs]:
    let result: U32 = 0
    for i: U32 in nums.iter().map(parseU32Exn).mapResult(handleInvalidDigit):
        result += i
    result

handleInvalidDigit(
    parseResult: Result[[InvalidDigit, ..errs], Option[U32]]
) Option[U32] / [..errs]:
    match parseResult:
        Result.Ok(result): result
        Result.Err(~InvalidDigit): Option.Some(0u32)
        Result.Err(other): throw(other)

InvalidDigit is no longer in the exception type of the function because mapResult(handleInvalidDigit) handles them.

We can also convert exceptions thrown by an iterator to Result values:

parseSumHandleInvalidDigitsLogRest(nums: Vec[Str]) U32:
    let result: U32 = 0
    for i: Result[[Overflow, EmptyInput], U32] in
            nums.iter().map(parseU32Exn).mapResult(handleInvalidDigit).try():
        match i:
            Result.Err(~Overflow): printStr("Overflow")
            Result.Err(~EmptyInput): printStr("Empty input")
            Result.Ok(i): result += i
    result

This function no longer has an exception type, because exceptions thrown by the iterator are passed to the loop variable.

In summary, we started with an iterator that doesn’t throw (nums.iter()), mapped it with a function that throws (map(parseU32Exn)), which made the for loop propagate the exceptions thrown by the map function. We then handled one of the exceptions (mapResult(handleInvalidDigit)), and finally, we handled all of the exceptions and started passing a Result value to the loop variable (try()).

The function’s exception type was updated each time to reflect the exceptions thrown by the function.

Once we had multiple parameter traits (which are important even without exceptions, and something we were going to implement anyway), no language features were needed specifically for the throwing iterators API that composes. Changes in the for loop type checking were necessary to allow throwing iterators in for loops. Composing iterators like iter().map(...).mapResult(...).try() in the examples above did not require any changes to the trait system or exceptions.

This demonstrates that Fir traits and exceptions work nicely together.

You can try the code in this blog post in your browser.

I’m looking for contributors

I’m planning a blog post on my vision of Fir, why I think it matters, and a roadmap, but if you already like what you see, know a thing or two about implementing programming languages, and have the time to energy to contribute to a new language, please don’t hesitate to reach out!


  1. I started this work in one country, and when finished, I was living in another! This PR really felt like an eternity to finish.↩︎

  2. Implementation-wise, it’s closer to Rust than Haskell as we monomorphise.↩︎

]]>
Error handling in Fir http://osa1.net/posts/2025-01-18-fir-error-handling.html 2025-01-18T00:00:00Z 2025-01-18T00:00:00Z 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

  • Safe: All exceptions need to be handled before main returns.
  • Flexible: All of the flexibility of variants shown above apply to exceptions as well.
  • Convenient:
    • Error values are implicitly propagated to the caller when not handled.
    • When a library uses one way of error reporting (error values or exceptions) and you need the other, conversion is just a matter of calling one function.

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

  • throw, which converts a variant into an exception:

    throw(exn: exn) a / exn
  • try, which converts exceptions into Result.Err values:

    try(cb: Fn() a / exn) Result[exn, a]
  • untry, which converts a Result.Err value into an exception:

    untry(res: Result[exn, a]) a / exn

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

parseU32Exn(s: Str) U32 / [InvalidDigit, Overflow, EmptyInput, ..r]:
    if s.len() == 0:
        throw(~EmptyInput)

    let result: U32 = 0

    for c in s.chars():
        if c < '0' || c > '9':
            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) Str / [IoError, ..r]:
    # We don't have the standard library support for file IO yet, just throw
    # an error for now.
    throw(~IoError)

parseU32FromFileExn(filePath: Str) U32 / [InvalidDigit, Overflow, EmptyInput, IoError, ..r]:
    parseU32Exn(readFileExn(filePath))

parseWithExn(vec: Vec[Str], parseFn: Fn(Str) a / exn) Vec[a] / exn:
    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) U32 / [InvalidDigit, Overflow, EmptyInput, ..r]:
    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.

]]>