· 7 min read ·

Go Has a Great Runtime. Lisette Wants to Give It a Better Type System.

Source: lobsters

The appeal of Go’s runtime has never been subtle. Goroutines that start at a few kilobytes of stack and scale to millions, a concurrent garbage collector that has reduced stop-the-world pauses from hundreds of milliseconds in early versions to consistently sub-millisecond in Go 1.14 and beyond, and channels that make CSP-style concurrency feel like a first-class part of the language. The runtime is genuinely good. The type system that ships with it is less so.

Lisette is betting that those two things are separable. The project puts Rust-like syntax over Go’s runtime model: algebraic data types, pattern matching, trait-style interfaces, Result<T, E> error handling, and iterator combinators, all running on top of the goroutine scheduler and garbage collector that Go users have been trusting in production for over a decade.

This is not a novel idea in programming language design, which is exactly what makes it interesting.

The BEAM Made This Pattern Famous

The most successful example of “better syntax on a great runtime” is Elixir. Erlang’s runtime (the BEAM) was built at Ericsson for telephone switching equipment: lightweight processes, preemptive scheduling, supervisor trees for fault tolerance, hot code reloading. The runtime engineering is exceptional. But Erlang’s syntax is famously unusual, with pattern-matched function clauses, comma-semicolon-period punctuation for blocks, and variable names that must start with capitals. It works, but it is not what most developers reach for.

José Valim built Elixir to fix the ergonomics without touching the runtime. It compiles to BEAM bytecode, runs on the same scheduler, and uses the same OTP patterns. It brought Ruby-like syntax and a macro system, and with it a much larger developer audience.

Gleam went further. It added static typing to the BEAM, with a syntax that visibly draws from Rust and ML: enum for algebraic data types, exhaustive pattern matching, Result for errors, no null, type inference. Gleam reached 1.0 in March 2024 and the reception suggested real demand for exactly this combination. Developers wanted the BEAM’s fault tolerance and the process model, but not the cognitive overhead of writing untyped Erlang.

Lisette is making the same argument for Go’s runtime that Gleam made for the BEAM’s.

What Go’s Runtime Actually Provides

It is worth being precise about what “Go runtime” means in this context, because the runtime is more than just a garbage collector.

Go’s goroutine scheduler is an M:N implementation: M goroutines are multiplexed across N OS threads via work stealing. A new goroutine starts with a stack around 8KB (the exact figure has varied across Go versions), and that stack grows dynamically via contiguous stack copying when it overflows. The practical result is that spawning a goroutine is cheap enough to do per-request in an HTTP server, which is exactly what the standard library’s net/http package does by default:

// Go's net/http spawns a goroutine per accepted connection
func (srv *Server) Serve(l net.Listener) error {
    // ...
    go c.serve(connCtx)
}

The GC has improved steadily. Go 1.5 introduced a concurrent, tri-color mark-and-sweep collector that moved most GC work off the main goroutines. Go 1.14 added asynchronous preemption, which eliminated a class of long GC pauses caused by tight loops that never yielded. In production, Go’s GC typically achieves stop-the-world pauses under one millisecond for workloads that are not pathologically allocation-heavy. Google, Cloudflare, and Uber have run Go at significant scale and the GC’s behavior under load is well-characterized.

Channels are typed, optionally buffered FIFO queues. The select statement lets a goroutine wait on multiple channel operations simultaneously, choosing whichever completes first. This is the language-level primitive for CSP-style coordination, and it handles a class of concurrent problems that would otherwise require condition variables and explicit signaling.

This is the runtime Lisette inherits. The question is what the language layer adds.

What Rust’s Syntax Brings Without the Borrow Checker

Rust’s syntax is almost always discussed in tandem with its ownership model, but several of its most useful features have nothing to do with memory management.

Algebraic data types via enum let you define types that are exactly one of a set of variants, each carrying its own data. In Go, you simulate this with an interface and a type switch. In Rust, or presumably in Lisette, you write:

enum Message {
    Ping,
    Text(String),
    Binary(Vec<u8>),
}

match msg {
    Message::Ping => handle_ping(),
    Message::Text(s) => handle_text(s),
    Message::Binary(data) => handle_binary(data),
}

The compiler checks exhaustiveness. Add a variant and forget to update a match arm, and the build fails. Go’s type switch provides no such guarantee.

Result<T, E> makes error handling composable in ways that Go’s multiple-return-value convention resists. The ? operator propagates errors up the call stack without explicit if err != nil at every call site:

fn process() -> Result<(), AppError> {
    let data = fetch_data()?;
    let parsed = parse(data)?;
    write_output(parsed)?;
    Ok(())
}

Go developers often describe the if err != nil pattern as boilerplate that clutters business logic. The complaint is not wrong; it is just the cost of Go’s explicit error model without syntactic sugar. Result with ? is that sugar, expressed as a type.

Iterator combinators, .map(), .filter(), .fold(), and their relatives, are ergonomic transformations over sequences that compose without manual loop accumulation. Go’s answer is typically a for range loop, which is readable but verbose compared to a chained expression.

Generics in Go 1.18 work, but the constraint system is less expressive than Rust’s trait bounds and has had less time to develop community idioms. Lisette, with Rust’s trait-style generics over Go’s runtime, would give developers significantly more flexibility in writing reusable code.

None of this requires a borrow checker. These are type system and syntax features that function equally well under garbage collection.

The Trade-off That Does Not Disappear

Dropping ownership in exchange for GC is a legitimate design choice, but it is not without cost.

Rust’s ownership system prevents data races at compile time. If a type does not implement Send, the compiler rejects any attempt to move it across a thread boundary. If it does not implement Sync, you cannot share a reference to it between threads. These guarantees are enforced statically, with zero runtime overhead.

Go enforces neither of these things statically. The race detector (go test -race) catches races at runtime during testing by instrumentating memory accesses, but production binaries typically run without it. Lisette, running on Go’s runtime, inherits this model. You can have the nice enum types and exhaustive pattern matching, but two goroutines can still share mutable state and create a data race if the programmer is not careful.

This is not a fatal flaw. The mental model of communicating via channels and protecting shared state with mutexes is workable, and most Go programs are correct. It means that Lisette’s safety story is “GC prevents use-after-free and null dereference” rather than “the type system prevents data races,” which is a meaningfully weaker claim than what Rust provides.

The other thing ownership enables is RAII: resource cleanup tied deterministically to scope exit. In Rust, dropping a file handle closes the file; dropping a lock guard releases the lock. These are guaranteed by the type system without requiring explicit cleanup calls. Go has defer for this, which works but requires the programmer to remember to write defer f.Close() rather than having the language enforce it. Lisette would presumably follow Go’s defer model.

The place where ownership-as-syntax causes the most friction in a GC language is references. In Rust, &T and &mut T are types with lifetimes attached; the borrow checker tracks where they can flow. In a GC context, you just have T (or a pointer to it), and the collector decides when it goes away. Lisette presumably drops the reference/borrow distinction entirely and relies on GC reachability. That simplifies the mental model considerably, which is arguably the point.

Whether the Bet Pays Off

The Gleam case suggests there is real appetite for this design. Developers want to write in expressive, type-safe languages without abandoning proven runtime infrastructure. The Elixir ecosystem grew substantially once Elixir made the BEAM approachable; Gleam is doing the same thing for developers who want the type system guarantees that Erlang cannot provide.

Go’s runtime is well-suited to this treatment for a different class of applications. Go’s standard library covers HTTP, TLS, SQL, JSON, gRPC, and more. The ecosystem for cloud-native tooling, Kubernetes controllers, and network services is mature. The deployment story is excellent: single static binaries, fast compilation, easy containerization. If Lisette can surface Rust’s ergonomics without requiring developers to internalize ownership and lifetimes, it occupies a real niche between “Go is ergonomically frustrating” and “Rust has too steep a learning curve for my application’s requirements.”

The project at lisette.run is early, but the design philosophy it represents has worked before. The runtime is the hard part to build; language syntax and type systems are more tractable to innovate on top of. Whether it works for Go depends on how completely Lisette implements the features that make Rust feel good to write, and how transparent the Go runtime plumbing remains once the language layer sits on top of it. The syntax is the pitch. The runtime is the foundation. The question is whether developers who are currently writing Go will find the trade a good one.

Was this interesting?