Discovering Lisette, a language that looks like Rust and compiles to Go

22 days ago / , ,
Simplified ChineseEnglish
Summary:
I've always liked Rust's syntax but struggled with ownership, and I even tried writing a language of my own. Lisette takes a route I hadn't considered, using Rust-style syntax to compile to Go.

I've always loved Rust's syntax. Its type system, pattern matching, method chaining, and approach to abstraction all feel right to me. It's pleasant to read and to write: pretty much what I'd want a language to look like.

But ownership often gets in the way of getting things done. Of course, in the age of vibe coding, I rarely have to type every line myself, so the friction is less noticeable. Still, whenever I write code by hand, I find myself wondering how nice it would be to have a language with Rust's intuitive syntax, pattern matching, and method chaining—everything except ownership—alongside Go's convenient garbage collection and concurrency.

Recently I came across Lisette. It takes an approach that had never occurred to me.

The language I wanted

What I wanted falls into two parts.

On the Rust side, my favourite combination is enums and pattern matching. When a value can take one of several forms, an enum spells them out, and match handles each one. Miss a variant and the compiler tells you. Add Option and Result, and missing values and errors become part of the type system; ? makes propagating errors straightforward. Method chaining feels good too: with a sequence of map and filter calls, you can follow the code in order without jumping back and forth in your head.

On the Go side, I like the garbage collector and concurrency model. A GC means I don't have to keep track of who owns what or who borrowed from whom, while goroutines and channels make concurrency possible without much ceremony. Compilation is fast, and you can usually build a standalone binary that's easy to distribute, which keeps deployment simple.

So I wanted half of each: Rust's syntax and type system, with Go's runtime.

Trying to write my own language

If it didn't exist, I could write it myself. I did actually try.

For a while, I spent well over ten billion tokens on state-of-the-art models and produced hundreds of thousands of lines of Zig. But a complete language needs so much: lexing, parsing, type inference and checking, code generation, a runtime, a GC, and a standard library. And that's just the language itself. To make it pleasant to use, you also need a formatter, an LSP, and package management. Each of those is a substantial project on its own.

I accumulated plenty of code, but a usable language was still a long way off. Eventually, I let it go.

What Lisette is

Lisette's approach is to skip writing a runtime altogether. It describes itself as a small Rust-inspired language that compiles to Go. The author's description is “A love letter to Go written in Rust.”

You write Rust-style code in .lis files. The compiler itself is written in Rust; it translates Lisette into Go code, then hands that code to the Go toolchain. What you end up running is an ordinary Go program, using the Go runtime, garbage collector, and scheduler. The standard library and third-party Go modules are available too.

That immediately provides the parts I had found most daunting: the runtime, GC, concurrency, and standard library, all backed by mature implementations. The language itself is left with the frontend and code generation. Even so, the compiler is hardly small: it has nearly 200,000 lines of Rust, with over 40,000 devoted to generating Go code. Even a frontend and a translation layer are a lot of work.

The project is new, but the author updates it frequently and maintains it very actively. Among personal programming-language projects I've followed, it has been one of the most consistently maintained.

What the code looks like

Here's an enum with some pattern matching.

Rust
enum Shape {
  Circle(float64),
  Rectangle { width: float64, height: float64 },
}

fn area(shape: Shape) -> float64 {
  match shape {
    Circle(r) => 3.14 * r * r,
    Rectangle { width, height } => width * height,
  }
}

It looks just like Rust, except the type names come from Go, such as float64. Leave a variant out of match, and the compiler reports it here too.

For chaining, Lisette adds a pipe operator, |>. Function calls that would otherwise be nested in Go can be read from top to bottom.

Rust
let slug = "  Hello World  "
  |> strings.TrimSpace
  |> strings.ToLower
  |> strings.ReplaceAll(" ", "-")  // "hello-world"

There's no nil, either. Values that might be absent use Option<T>, and operations that might fail use Result<T, E>. Both ? and let ... else are available, as in this example.

Rust
let Ok(bytes) = os.ReadFile(path) else {
  fmt.Println("could not read")
  return
}

Bindings are immutable by default, with let mut for mutable ones. There are impl blocks, generics, expression-style if and match, and f-strings. It all reads a lot like Rust.

The Go code it generates

What does the generated Go code look like? I tried a guarded match over an enum with three variants.

Rust
enum Shape { Circle(int), Square(int), Tri(int) }

fn test(s: Shape) -> int {
  match s {
    Circle(r) => r,
    Square(w) => w,
    Tri(h) if h > 10 => h * 2,
    Tri(h) => h,
  }
}

Here's the resulting Go code. I've left out constructors such as MakeShapeCircle.

GO
type ShapeTag uint8

const (
	ShapeCircle ShapeTag = iota
	ShapeSquare
	ShapeTri
)

type Shape struct {
	Tag    ShapeTag
	Circle int
	Square int
	Tri    int
}

func test(s Shape) int {
	if s.Tag == ShapeCircle {
		return s.Circle
	}
	if s.Tag == ShapeSquare {
		return s.Square
	}
	h := s.Tri
	if h > 10 {
		return h * 2
	}
	return h
}

The enum becomes a tagged struct, and match becomes a series of if statements. Where the branches fit, it can generate a switch instead. Overall, it looks like code someone familiar with Go might have written by hand. Many of the additions in the last two releases have focused on making generated Go code more idiomatic—for example, translating or-patterns into multiple case values in a switch. The author seems to have put quite a bit of thought into this.

Garbage collection instead of ownership

Lisette has neither ownership nor Rust's borrowing system, so the compiler errors that tend to frustrate me aren't there. What does that change in practice?

For example, assigning a non-Copy value such as a String in Rust moves it. Lisette doesn't use these move semantics, so the original variable remains usable after assignment.

Rust
let s1 = "hello"
let s2 = s1
fmt.Println(s1)  // 没问题,s1 还能用

In Rust, a string literal has type &str, which is Copy; the move behaviour above concerns non-Copy values such as String.

Rust's String and &str become a single string type, with no owned-versus-borrowed distinction. Closures capture by reference, and the GC keeps captured values alive, so there's no move keyword or borrow-checking rules. Traits are replaced by Go-style interfaces: a type implements an interface if its methods match, without an impl Trait for declaration. Incidentally, there's no unwrap(), either.

References do exist, written as Ref<T> and mut Ref<T>, roughly corresponding to Rust's &T and &mut T. Multiple mut Ref<T> references can coexist, though; they don't have Rust's exclusivity requirement. Whether you can write through one depends on the relevant binding or field declaration.

Most of the Rust features I like are still there, with ownership replaced by garbage collection.

Concurrency

Concurrency follows Go's model directly. The language has built-in channels and task, where a task is a goroutine: task { ... } becomes go func() { ... }().

Rust
let (tx, rx) = Channel.new<string>().split()
task {
  tx.send("hello")
  tx.close()
}
match rx.receive() {
  Some(msg) => fmt.Println(msg),
  None => fmt.Println("closed"),
}

rx.receive() returns an Option, with None when the channel is closed. The condition you'd check in Go with v, ok := <-ch is handled through match instead. There's also select.

Working with Go

I find the interop design particularly pleasant. Import Go packages with import "go:...", and using them feels much like using them in Go.

Rust
import "go:os"
import "go:encoding/json"
import "go:fmt"

fn load_config(path: string) -> Result<Cfg, error> {
  let data = os.ReadFile(path)?
  let mut config = Cfg { port: 8080, debug: false }
  json.Unmarshal(data, &config)?
  Ok(config)
}

In Go, os.ReadFile returns ([]byte, error). In Lisette, that is automatically mapped to Result, so you can use ? directly. Go pointers, maps, and interfaces that can be nil similarly become Option at the boundary.

Rust
// Go:  func find(id int) *Person
// Lis: fn find(id: int) -> Option<Ref<Person>>

match find(1) {
  Some(person) => fmt.Println(person.name),
  None => fmt.Println("no person"),
}

That removes nil from Lisette: you either have None, or you have a value. The mapping is described in a type declaration file, with the .d.lis extension. To use a third-party Go module, a command such as lis add google/uuid downloads it, generates the declarations, and records the dependency in lisette.toml.

Compile-time checks

Lisette checks for many common runtime errors and pitfalls in Go code at compile time. There are over 500 diagnostics. Besides nil-related issues, these cover missing match variants, silently discarded Result or Option values, calls to os.Exit that bypass defer, forgotten context cancellation functions, and calling WaitGroup.Add inside a task. It even warns when you write file permissions as a decimal number.

Many of the traps that are easy to fall into when writing Go code are guarded by the compiler and the language syntax.

The toolchain

The command-line tool is called lis, and it covers most of what I'd expect. lis new creates a project with src/main.lis, lisette.toml, and an AGENTS.md for AI agents. lis run and lis build compile and run code, with --target available on the latter for cross-compilation. lis emit generates only the Go source, which is useful when I want to inspect the translation. There's also lis check for type checking and linting, with --fix for automatic fixes, along with lis format, lis test for #[test] functions, and the lis add command mentioned earlier.

Single-file programs can run as scripts too. Add a shebang at the top, and the file can be executed directly.

Rust
#!/usr/bin/env -S lis run

import "go:fmt"
import "go:os"

fn main() {
  fmt.Println("hello,", os.Args[1])
}

For editor support, VS Code, Zed, and JetBrains have extensions available by searching for Lisette in their marketplaces. Neovim and Helix take a little manual configuration to connect them to lis lsp. There's also a browser-based playground, so you can try it without installing anything.

Trying it out

Since the final compilation goes through the Go toolchain, you need Go 1.26 or later installed first. Lisette can then be installed in any of these ways.

Shell
curl -fsSL https://lisette.run/install.sh | sh
# 或者
brew install lisette
mise use -g lisette
cargo install lisette

Once it's installed, create a project and run it.

Shell
lis new hello-world
cd hello-world
lis run

The repository is ivov/lisette, and the documentation is at lisette.run.

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...