Notes
2026/02/22
How can you jump-start a programming language in an age of batteries-included standard libraries? One idea that I want to explore is what I call “symbiotic programming”, basically the idea of designing a programming language specifically to interact symbiotically with a host environment or ecosystem.
This idea isn't exactly new, many languages have been designed with interop in mind, for example embedded languages like Lua or languages targeting existing VMs like Clojure or Scala. But these languages still build a lot of functionality into their own standard libraries, because interacting with the host language or VM can feel foreign. What if a language were designed to do as little as possible, and just yield control back to the host whenever it needs to do anything non-trivial?
The word “yield” already gives away the main implementation idea: Rely as much as possible on coroutines / continuations that can yield execution back to the host, suspending the call stack of the symbiotic guest as a result. The host effectively acts as an effect handler which can handle the effect raised by the coroutine (or decide not to) and then resume where the guest left off (or decide not to).
There are many ways to implement this, but a simple approach (that might come at the cost runtime efficiency) is using a lightweight stack-based virtual machine that can be suspended and resumed cooperatively between guest and host. An effect that is raised within the virtual machine can then either be caught and handled by an effect handler defined in the guest language, or bubble up to the host, in which case the continuation effectively wraps the entire state and call stack of the virtual machine.
Should continuations be one-shot or multi-shot, in other words can they be resumed just once or multiple times? An easy answer in host languages with ownership semantics like Rust (but also a bit of a cop out) is to treat an entire VM continuation as a value that is consumed when the continuation is resumed. A multi-shot continuation then comes down to cloning the entire VM. Not the most efficient, but perhaps good enough for a first version?
Here's a proposal for a concrete syntax: Any variable not ending with ! is resolved lexically, as usual. Any variable ending with ! is treated as an effect and resolved dynamically depending on which effect handlers are on the effect handler stack (or ultimately by the host language) when the effect is “raised” by being called, like read!(...). An effect can be caught and handled in the guest language:
try({
// ...
read!("some_file.txt")
// ...
}, [
read!: (resume, arg) => {
contents = // read the file somehow, based on the arg
resume(contents)
}
write!: (resume, arg1, arg2) => {
// ...
}
])
Handling an effect requires a built-in construct like try, which executes a block { ... } of code with a collection of effect handlers in scope. Each effect handler catches a specific effect (by referring to the name of the effect) and handles it using an anonymous function of at least two arguments: the argument(s) with which the effect was called and a continuation that can resume the code at the point where the effect was raised.
While the arguments of an invoked effect can be inspected by an effect handler to determine whether to actually handle an effect (or raise the effect again, leaving it for outer effect handlers to handle), using the name of an effect explicitly as part of a key + value map of effects + effect handlers allows a form of static dispatch that makes the implementation a lot more efficient: Instead of having to literally walk the entire effect handler stack by checking every single effect handler, the virtual machine can store the stack of effect handlers as a map of stacks, effectively bypassing all the handlers for unrelated effects. If the names of effects are only allowed to be statically known / interned strings (as opposed to being able to create effect names dynamically through string manipulation), this map of effect-names-to-handlers can even be implemented as an array access, because all effect names are known at compile time.
It's tempting to keep the language minimal by going with lambda calculus and multi-argument-functions-through-currying, in other words with lambdas that only have a single function argument. This is a bit awkward when the goal is to let an effect bubble up and interact with the host though, because it's unclear when an effect has received “all” of its arguments and needs to be handled by the host.
Control could of course be passed back to the host every time an effect is called with an argument, with the host being expected to resume the (partially applied) effect until the host has enough arguments, but that leads to a very awkward API. Another option is to keep regular functions single-argument and curried, but use fixed arity for effects. The easiest and cleanest option is to diverge from lambda calculus by giving both functions and effects a fixed arity. (Should effect handlers be allowed to overload effects with multiple arities? It's probably easiest to raise an error in such a case, so that calling an effect with the wrong number of arguments fails in the same way as calling a regular function with the wrong number of arguments.)
Here's a proposal for how to benchmark such a tiny language implementation: Let's give the language integers, but implement them in four different ways, both symbiotically and fully within the guest language:
+, -, *, /, and % as part of the bytecode (in other words built into the VM).add!(2, 2)).The efficiency of the bytecode operations (built into the VM) can then be benchmarked against the overhead of the host interop and the much higher overhead of matching on binary or even Peano encodings.
In my initial micro benchmarks using factorial and fibonacci, using host interop to handle arithmetic (with a Rust host) is about 10 times slower than using built-in arithmetic instructions (while Peano arithmetic is 1000 times slower or worse). Being 10 times slower is not great, but it's a decent start and could make it viable to implement arithmetic fully through the host if numeric performance isn't the primary goal of the language.
How feasible would it be to offload even core operations like arithmetic to the host? I'm not aware of a language that goes as far as this, but it might be an interesting experiment to run.