osa1 github gitlab twitter cv rss

Resumable exceptions

November 4, 2024 - Tagged as: en, plt.

The main use case of resumable exceptions would be collecting a bunch of errors (instead of bailing out after the first one) to log or show to the user, or actually recovering and continuing from the point of error detection, rather than in a call site.

Why not design the code to allow error recovery, instead of using a language feature? There are a few problems with this:

Example use case: parser shared by a compiler and a language server

Modern programming languages have complex syntax. Parsers for these languages are often thousands of lines of code.

Ideally, all tooling for a language would share the parser, as it’s a significant amount of work to implement, debug, maintain parsers for such large languages.

However not all of these tools will have the same error handling behavior. A compiler cannot continue in the presence of a parse error, but a language server has to continue.

With resumable exceptions, the compiler can abort after a parse error, the language server can provide placeholder AST nodes for failing parse operations and resume. This flexibility does not make the parser API any more complicated than a parser that throws an exception in any other language. A one-off refactoring script that uses the parser library doesn’t have to deal with error recovery just because the language server, which uses the same parser, needs to recover from parse errors and continue parsing.

Types of resumable exceptions

With resumable exceptions throw expressions generate a value. The value depends on the exception type thrown. For example, a FooDecodingException can be resumed with a value of Foo provided by the handler.

This can be implemented with an abstract base class or typeclass/trait with a type parameter:

// in a system with classes:
abstract class ResumableException<Resume> { 
    prim Resume throw();
    prim Never resume(Resume resumptionValue);
}

// or in a system with typeclasses/traits:
trait ResumableException<Resume> {
    prim fn throw(self) -> Resume;
    prim fn resume(resumptionValue: Resume) -> !;
}

Here the prim keyword indicates that the throw and resume methods are provided by the compiler.

throw e can then be type checked as e.throw(), and resume e with value can be type checked as e.resume(value). Or we can use the function call syntax instead of special syntax for throwing and resuming.

Whether to make exceptions thrown by a function a part of its type signature or not is an orthogonal concern.

Exception type design considerations

The same considerations when designing non-resumable exceptions apply to resumable exceptions:

For example, it doesn’t make sense to resume from an ArgumentError, so we don’t implement ResumableException for it.

To be able to meaningfully resume from an exception, the exception type should document when exactly it is thrown, or have a resumption value type that is specific enough to give an idea on when it is thrown.

For example, an exception that can be resumed with an int cannot be resumed without knowing what that int is going to be used for, so this should be documented. But an exception FooDecodingError implements ResumableException<Foo> makes it clear that it’s thrown when there’s an error during decoding a Foo, and the resumption value is the value to be used as the Foo being decoded.