The Borrow Checker and the Primate: Inside jnsahaj/monkey-rs

How translating a Go-based interpreter into Rust forces a collision between garbage-collected assumptions and strict memory safety.

6 min read • View on GitHub • More from jnsahaj

A close-up of a rigid, perfectly ordered grid of interlocking steel cubes representing Rust. Inside one of the central cubes, a chaotic, swirling liquid representing Go's garbage collector is trapped behind a thick glass window.
Translating a garbage-collected language implementation to Rust requires finding an escape hatch for shared state.

While the primary goal was to get good at Rust, learning about how compilers and interpreters work has left me with profound knowledge applicable immediately.

jnsahaj, Project Creator · jnsahaj/monkey-rs
Key Takeaways

The Memory Model Collision

Thorsten Ball’s highly regarded "Writing an Interpreter in Go" provides a pragmatic architecture for executing a custom language called Monkey. It relies heavily on closures and shared environments. In Go, these constructs are trivial. The language's garbage collector quietly monitors the heap, cleaning up dangling references when a closure's environment outlives its parent scope.

Rust, however, forbids this casual relationship with memory. The compiler's ownership model demands explicit lifetimes and single ownership. When jnsahaj set out to port the Monkey interpreter to Rust, this fundamental difference created an immediate architectural collision. The Go code assumed a safety net that simply did not exist in the target language.

Escaping the Borrow Checker

To implement functional closures, the interpreter must allow multiple guest functions to hold references to the same overarching scope. A pure functional approach—passing the entire state matrix through every function call—would require a complete rewrite of the book's architecture. Instead, the project needed an escape hatch.

use std::cell::RefCell;
use std::rc::Rc;

pub type MutEnv = Rc<RefCell<Environment>>;

The solution lies in src/common/environment.rs. By wrapping the Environment struct in Rc<RefCell<T>>, the author leverages Rust's interior mutability and reference counting. The Rc (Reference Counted) smart pointer allows multiple closures to own the environment simultaneously. The RefCell defers borrow checking to runtime, allowing the environment to be mutated even when shared.

The Rc pattern provides a bridge between Go's implicit garbage collection and Rust's explicit ownership rules.

Porting as Pedagogy

The value of jnsahaj/monkey-rs isn't in introducing a novel programming language paradigm. Its value is pedagogical. By tackling a known specification—where the logic of parsing if-statements and evaluating infix operators is already solved—the developer isolates the challenge entirely to the constraints of the host language.

WSJ hedcut-style portrait of jnsahaj

The project serves as a clear blueprint for systems-minded engineers looking to understand how high-level abstractions map to low-level memory constraints.

Two Paths to Execution

The repository houses a dual implementation. The frontend parser generates an Abstract Syntax Tree (AST) that can be fed into either a tree-walking interpreter (src/interpreter) or a stack-based virtual machine (src/compiler).

A split illustration. On the left, a botanist meticulously traces the branches of a complex tree with a magnifying glass. On the right, an industrial factory line stamps identical metal punch cards into a hopper.
The tree-walking interpreter traverses complex recursive structures, while the virtual machine executes flat, linear bytecode instructions.

The interpreter evaluates the AST recursively. It is conceptually simple and straightforward to debug, as every node implements the Display trait. However, this recursive evaluation is slow. The compiler, conversely, flattens the AST into custom bytecode, pushing and popping instructions onto a stack array. It trades the elegance of recursion for the raw speed of linear execution.

FeatureTree-Walker (Interpreter)Bytecode VM (Compiler)
Execution TargetAbstract Syntax Tree (AST)Flattened Bytecode
State ManagementRecursive function callsLinear stack array
DebuggingHigh-level string re-parsingLow-level hex dumps

The Elegance of Pratt Parsing

A hidden gem within the codebase is the parser implementation in src/common/parser.rs. Instead of relying on immense, nested match statements or parser-generator tools, the project employs a Top-Down Operator Precedence parser, often called a Pratt parser.

By utilizing a custom expect_peek! macro and leveraging Rust's powerful enum pattern matching, the logic required to parse complex mathematical expressions and operator precedence is reduced to highly readable, idiomatic code. It demonstrates how Rust's type system can make writing manual parsers a surprisingly ergonomic experience.