You write Go. You get Rust. That's the idea behind
Goany — a transpiler that takes
ordinary Go programs and turns them into working Rust code, ready to compile
with cargo build.
But Go and Rust have fundamentally different ideas about memory. Go has a garbage collector that handles everything for you. Rust has an ownership system that demands you think about every value's lifetime. Bridging that gap is where all the interesting problems live.
Why Transpile Go to Rust?
Go is great for writing clear, readable code quickly. Rust is great for performance and safety without a garbage collector. What if you could write in Go and deploy in Rust?
That's what Goany does. You write your program in Go, test it with go run,
and when you're happy, transpile it to Rust. The generated code compiles, runs, and
produces the same output — no manual fixups needed.
This isn't a toy. Goany handles real programs: CPU emulators, language interpreters, GUI applications, HTTP servers, parser generators. All transpiled automatically.
A Subset of Go — By Design
Goany doesn't support all of Go, and that's intentional. The goal isn't to port existing Go programs to Rust. It's to give you a portable language for writing libraries and programs that can target multiple backends — Rust, C++, C#, JavaScript, Java — from a single source.
Go serves as that source language because it's simple, readable, and has excellent tooling. But you're writing for Goany, not trying to shove an existing Go codebase through it.
The supported subset is large enough to write real, non-trivial programs:
- Functions, structs, constants, type aliases
- Slices, maps, strings, integers, floats, booleans
- If/else, for loops, range loops
- Multiple return values
- Closures (with some restrictions)
- Interface{} and type assertions
- Multi-package programs
What's deliberately outside the scope:
- Goroutines and channels — concurrency models differ too much across targets
- Pointers — Rust ownership makes direct pointer translation impractical
- Method receivers — use plain functions with the struct as the first argument
- Defer, select, switch — use if/else chains and explicit cleanup instead
- The full standard library — Goany provides its own runtime for I/O, networking, and graphics
Supporting everything in Go is theoretically possible but not realistic — and more importantly, not the point. Every feature added to the transpiler must work across all backends. Goroutines might make sense in Go, but they have no natural equivalent in C# or JavaScript. Pointers translate to Rust differently than to Java. By keeping the language subset focused, every program you write is guaranteed to work everywhere.
Think of it as writing in a portable dialect of Go. You get Go's syntax, Go's type checker, Go's tooling for development and testing — but you stay within the subset that transpiles cleanly to any target.
The subset grows over time, driven by real programs. The test suite — a CPU emulator, a Python interpreter, a Go parser, a GUI toolkit — pushes the boundaries. When a new program needs a feature, we add it, verify all existing programs still work across all backends, and ship it.
How It Works
The transpiler reads your Go source, runs it through Go's own type checker to understand every expression's type, then walks the syntax tree node by node, translating each piece to its Rust equivalent.
A simple Go function like this:
func Add(a int, b int) int {
return a + b
}
Becomes:
pub fn Add(mut a: i32, mut b: i32) -> i32 {
return a + b;
}
That's the easy case. The interesting stuff happens when Go's convenience features meet Rust's strictness.
The Big Idea: Clone First, Optimize Later
In Go, you can pass values around freely. Copy a struct, share a slice, hand a map to three different functions — the garbage collector sorts it all out.
Rust doesn't work that way. Every value has exactly one owner. If you want to share, you have to be explicit about it.
Goany's approach: make it work, then make it fast. The first translation
clones everything — every function argument, every struct field access, every map
read gets a .clone() call. This is correct. It compiles. It runs.
It gives the right answers.
// Translated Go code, before optimization:
c = cpu::Step(c.clone(), addr.clone());
let x = state.lines.clone();
Yes, it's slow. We're deep-copying data structures that don't need copying. But correctness comes first. Once we have working code, optimization passes strip away the clones that aren't needed.
What Changes Between Go and Rust
Most Go types have natural Rust equivalents:
intbecomesi32stringbecomesString[]int(a slice) becomesVec<i32>bool,float64— same idea, different names
But some Go features don't have simple translations.
Maps Get Complicated
Go maps are effortless. Declare one, put stuff in, take stuff out:
m := make(map[string]int)
m["hello"] = 42
fmt.Println(m["hello"])
In Rust, there's no built-in equivalent that handles Go's dynamic typing. Goany uses a custom HashMap where values are stored as type-erased pointers, and you cast them back when you read:
m = hmap::hashMapSet(m, Rc::new("hello".to_string()), Rc::new(42));
let val = hmap::hashMapGet(&m, Rc::new("hello".to_string()))
.downcast_ref::<i32>().unwrap().clone();
It's verbose, but it preserves Go's semantics exactly. The transpiler generates all of this automatically — you never see it unless you look at the output.
Nested maps (m[k1][k2] = value) are even trickier. In Go, it's one line.
In Rust, you can't mutate through a chain of lookups. The transpiler extracts the inner
map, modifies it, and writes it back — like unwrapping a Russian doll, changing
something inside, and putting it all back together.
Closures Meet the Borrow Checker
Go closures casually capture variables from their surroundings:
count := 0
increment := func() { count++ }
increment()
increment()
fmt.Println(count) // prints 2
Try doing this directly in Rust and the borrow checker will have words with you.
A mutable closure that captures count would conflict with any other
use of that variable.
Goany's solution is pragmatic: for closures that stay local, it inlines them at every call site. Instead of creating a closure object, the transpiler splices the closure's body directly where it's called. No closure, no borrowing problem.
Strings Are Surprisingly Different
Go strings look simple, but the details diverge from Rust in many small ways:
s := "hello"
c := s[0] // byte access
sub := s[1:3] // slicing
combined := s + " world"
n := len(s)
Each of these needs a different translation:
let s = "hello".to_string();
let c = s.as_bytes()[0] as i8; // byte access needs explicit conversion
let sub = s[1..3].to_string(); // slicing creates a new owned String
let combined = s + &" world".to_string(); // Rust requires &str on the right
let n = s.len() as i32; // Rust returns usize, Go expects int
None of these are hard individually. But there are dozens of these small translations, and they all have to be right.
Interfaces Become Trait Objects
Go's interface{} (or any) lets you store any type and
retrieve it later with a type assertion. Rust's equivalent is Rc<dyn Any>
— a reference-counted pointer to a type-erased value:
// Go
var x interface{} = 42
val := x.(int)
// Rust
let x: Rc<dyn Any> = Rc::new(42_i32);
let val = x.downcast_ref::<i32>().unwrap().clone();
Making It Fast: Removing Unnecessary Clones
The clone-everything approach works, but produces code that copies data structures far more than necessary. Two optimization passes fix this.
Passing by Reference Instead of Cloning
Many functions only read their parameters — they never modify them, never return them, never store them elsewhere. For these, we can pass a reference instead of a full copy:
// Before: deep-copy the entire vector
pub fn AssembleLines(mut lines: Vec<String>) -> Vec<u8> { ... }
AssembleLines(lines.clone());
// After: just borrow it
pub fn AssembleLines(lines: &Vec<String>) -> Vec<u8> { ... }
AssembleLines(&lines);
The transpiler analyzes every function to determine which parameters are read-only, then rewrites both the signature and every call site. This works across package boundaries — if your main program calls a library function, the transpiler knows whether that library function's parameters are safe to borrow.
Moving Instead of Cloning
When you write c = Step(c) in Go, the old value of c is
discarded. In the generated Rust, there's no reason to clone it — the old value
is about to be overwritten, so we can just move it:
// Before: pointless clone — c is overwritten immediately
c = cpu::Step(c.clone());
// After: move the value, no copy needed
c = cpu::Step(c);
This matters most for struct-heavy code. In a CPU emulator, the CPU state struct might have dozens of fields. Moving it is free; cloning it copies every field.
Sometimes a struct is both moved and has its fields read in the same call. The optimizer handles this by extracting the field values first:
// Can't move c and read c.PC at the same time
// Solution: save the field, then move
let __mv0: i32 = c.PC;
c = cpu::Step(c, __mv0);
How Much Does It Help?
On real programs, these optimizations remove hundreds of unnecessary clones:
| Program | Clones removed |
|---|---|
| 6502 CPU emulator | 1,148 |
| Python interpreter | 1,189 |
| Go parser | 1,335 |
| GUI application | 212 |
That's hundreds of deep copies of vectors, strings, and structs that no longer happen.
What Can You Actually Transpile?
Goany handles real, non-trivial Go programs:
- A MOS 6502 CPU emulator — complete with assembler, disassembler, and pixel-level graphics output
- A Python interpreter — parses and executes Python source code, including variables, functions, loops, and data structures
- A Go parser — tokenizes and builds an AST from Go source files
- GUI applications — buttons, checkboxes, sliders, text rendering
- HTTP client and server programs
- TCP networking — echo servers with multiple connections
- A SQL-like query engine — filters and aggregates in-memory data
All of these compile from Go source to a Rust binary with cargo build --release
and produce identical output to the Go original. No hand-editing the generated code.
The Runtime
Some Go features need small Rust helper libraries. The transpiler bundles these and includes them automatically when needed:
- A custom HashMap implementation for Go-style maps
- Networking helpers for TCP connections
- HTTP client and server wrappers
- File system operations
- Graphics backends (SDL2 and TIGR)
When you transpile a program that uses networking, the transpiler adds the right
dependencies to Cargo.toml and copies the runtime module into your project.
You just run cargo build and it works.
Why Not Just Use an LLM?
With modern LLMs capable of translating code between languages, a fair question is: why build a transpiler at all? The answer comes down to correctness and consistency.
An LLM can translate a single function from Go to Rust and often get it right. But ask it to translate a multi-package program with thousands of lines, and the results become unpredictable. It may rename variables, change control flow, introduce subtle type mismatches, or simply hallucinate logic that wasn't in the original. Every translation is a roll of the dice — you get a different output each time, and verifying correctness requires reading every line of the generated code.
A transpiler, by contrast, is deterministic. The same input always produces the same output. Once you verify that a translation rule is correct, it stays correct for every program that uses it. The clone optimization passes described above are a good example — they require whole-program analysis of ownership and data flow, something an LLM cannot reliably do across files and packages.
That said, LLMs are excellent companions to a transpiler. They can help write individual translation rules, generate test cases, and prototype new features quickly. Goany benefits from LLMs during development — but the transpiler itself is what guarantees that the output compiles, runs, and produces identical results every time.
Conclusions
Get it correct first. Cloning everything sounds wasteful, but it let us build a working transpiler quickly. Once you have correct output, you can optimize surgically. The alternative — trying to be clever about ownership from the start — leads to subtle bugs that are hard to track down.
The borrow checker is the real challenge. Translating individual expressions is mostly mechanical. The hard part is satisfying Rust's ownership rules for patterns that Go handles implicitly — nested map mutations, mutable closures, struct fields passed to functions while the struct itself is being modified.
Type information makes everything possible. The transpiler relies heavily on Go's type checker. "Is this value a struct or an integer? Is this map key a string? Does this function parameter get mutated?" Without type information, every one of these questions would require guessing.
Optimization is about seeing the big picture. To know whether a
.clone() is necessary, you need to understand the whole context: what's
on the left side of the assignment, what the called function does with its parameters,
whether the variable is used again later. These are whole-program questions, not local ones.
Goany is open source at github.com/pdelewski/goany. It also supports C++, C#, JavaScript, and Java backends.