· 6 min read ·

Rust's Type System on Go's Runtime: What Lisette Is Actually Building

Source: lobsters

The pairing sounds like a thought experiment: Rust syntax sitting on top of Go’s runtime. But Lisette is making exactly this bet, and once you understand what each half of that description actually contributes, the design logic becomes clear.

The claim is not that Lisette is “Rust but easier” or “Go but fancier.” It is something more specific: take the parts of Rust’s type system that have nothing to do with memory ownership, and run them on a runtime that already handles memory for you. The result occupies a position in the language design space that, until recently, nothing filled particularly well.

Go’s Type System Is the Problem

Go’s runtime is genuinely excellent. Goroutines start at roughly 2KB of stack and grow dynamically, compared to the 1-8MB typically reserved for OS threads. The scheduler uses an M:N model, multiplexing many goroutines onto a smaller number of OS threads using a work-stealing algorithm. As of Go 1.14, goroutines are preemptible at arbitrary safe points, not just function call boundaries. The garbage collector is concurrent tri-color mark-and-sweep, with stop-the-world pauses that for most server workloads land well under a millisecond.

None of that is the problem. The problem is what you have to write around it.

Go has no sum types. There is no way to define an enum where each variant carries different data, which means modeling something like a network packet, a parse result, or an event type requires either a struct with tagged fields and ignored members, or an interface with a type switch that the compiler will not check for exhaustiveness.

// The Go idiom for a sum type
type Shape struct {
    Kind   string // "circle" or "rectangle"
    Radius float64 // only meaningful when Kind == "circle"
    Width  float64 // only meaningful when Kind == "rectangle"
    Height float64 // only meaningful when Kind == "rectangle"
}

This is not a theoretical concern. Every non-trivial Go codebase has structures like this, and the compiler offers no help when you add a new Kind value and forget to update one of the places that switches on it.

Error handling is the other recurring friction point. The idiomatic Go pattern returns (value, error) tuples:

result, err := doFirstThing()
if err != nil {
    return nil, err
}

processed, err := doSecondThing(result)
if err != nil {
    return nil, err
}

For multi-step operations, this is mechanical boilerplate, and it is everywhere. Generics arrived in Go 1.18 but did not change the error handling story in any fundamental way. The language’s simplicity is a genuine virtue, but its type system has real expressive gaps that cannot be papered over without language-level changes.

What Rust’s Syntax Actually Brings

Rust’s reputation is dominated by the borrow checker, but the borrow checker is a small part of what makes Rust’s syntax expressive. Strip out lifetimes and ownership annotations, and you are left with a type system that Go developers would find genuinely useful:

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

fn area(s: Shape) -> f64 {
    match s {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
    }
}

The compiler enforces that every variant is handled. Add a Triangle variant later and every match in the codebase becomes a compile error until you handle it. This is exhaustiveness checking, and it is one of the most practically useful features a type system can offer.

Rust’s Result<T, E> and the ? operator compress the multi-step error propagation pattern:

fn process() -> Result<Output, Error> {
    let result = do_first_thing()?;
    let processed = do_second_thing(result)?;
    Ok(processed)
}

Each ? desugars to “return the error early if this failed, otherwise unwrap the success value.” This is functionally equivalent to Go’s if err != nil { return nil, err } chain, but without the noise. Traits, generic bounds, and impl blocks round out an expressive surface that Rust uses for its entire standard library and ecosystem.

None of these features have anything to do with ownership or the borrow checker. They are orthogonal to memory management. And if you have a garbage collector handling memory, you do not need the borrow checker to guarantee safety, which means you can drop it entirely.

The GC Bargain

This is what Lisette is trading. By running on Go’s runtime, you accept a garbage collector and give up Rust’s zero-cost abstraction guarantee. The GC means you cannot make the same latency promises Rust makes, and you carry a runtime overhead that a pure Rust binary would not have.

For most workloads, this is a reasonable trade. Go’s GC pause times in production are routinely sub-millisecond. The throughput impact depends heavily on allocation rate, but Go services routinely handle tens of thousands of requests per second without the GC becoming a bottleneck. The use cases where Rust’s no-GC story is genuinely irreplaceable, real-time audio, embedded systems, kernel modules, are not the use cases where most developers are choosing between Rust and Go anyway.

What you get in return is goroutines. Writing concurrent code with goroutines and channels is significantly more approachable than Rust’s async/await model or thread-based parallelism with Arc<Mutex<T>>. Goroutine-based code looks sequential even when it is concurrent, which makes it easier to reason about at scale. This is the part of Go that works so well that people keep reaching for it despite the type system frustrations.

A Pattern That Has Worked Before

The “better type system on a proven runtime” approach has a track record. Elixir launched in 2011 as a Ruby-inspired language running on the BEAM VM, which is the Erlang runtime. It preserved BEAM’s actor model and fault-tolerance guarantees while adding a more expressive syntax and macro system. Elixir is now used in production at significant scale by companies like Discord, Bleacher Report, and PagerDuty.

Gleam, which appeared around 2019, pushed this further by bringing ML-style static typing to BEAM. Gleam’s type system, explicitly influenced by Rust and Elm, gives you exhaustive pattern matching and a Result type on top of a runtime whose concurrency properties are unmatched. The combination works because the runtime and the type system solve different problems.

Kotlin followed the same logic on the JVM. Java’s runtime is mature and battle-tested, but Java’s type system was verbose and limited. Kotlin added null safety, data classes, sealed classes (which function like sum types), and a much cleaner syntax, all while compiling to bytecode that runs on the existing JVM and interoperates with the existing Java ecosystem.

The pattern is consistent: runtimes are expensive to build correctly, GCs take years to tune, concurrency schedulers require deep systems expertise. Type systems, relative to runtimes, are more tractable. Separating the two problems and solving each independently is a defensible engineering choice.

What Lisette Is Positioning For

The developers who will find Lisette most interesting are probably people who like Go’s concurrency model but find themselves working around its type system, or people who use Rust primarily for server-side work and spend significant time fighting the borrow checker for scenarios where GC would be fine.

Go’s goroutine model is particularly well-matched to network service workloads, where you are spawning a goroutine per connection or per request, doing I/O, and returning results through channels. That pattern does not require zero-cost abstractions. It requires cheap concurrency primitives, a good scheduler, and a network library. Go’s runtime provides all of that.

If Lisette can layer Rust’s more expressive type vocabulary onto that runtime cleanly, without the borrow checker friction, it occupies a position that neither Go nor Rust fully satisfies: expressive types, approachable concurrency, and a GC that handles the memory problem so you can focus on the problem you actually came to solve.

That is a real gap. Languages that fill real gaps tend to find their users.

Was this interesting?