A Native WebAssembly Toolkit for Go, and Why the IR Is the Story
Source: eli-bendersky
The WebAssembly tooling landscape has long been owned by two projects: wabt, the C++ reference toolkit from the WebAssembly org, and wasm-tools, Bytecode Alliance’s Rust implementation. Both are excellent. Neither is particularly usable from Go without friction.
Eli Bendersky just released watgo, a pure Go, zero-dependency WebAssembly toolkit that covers the same ground: parse WAT (WebAssembly Text Format) into a semantic module representation, validate that module against the WebAssembly spec, encode it to binary .wasm, and decode binary back into the module representation. It is not trying to replace wabt or wasm-tools for every use case. But for Go developers building anything that needs to manipulate or inspect WebAssembly modules, it changes the calculus significantly.
What the Alternatives Actually Require
To use wabt from Go, you have two options. You can shell out to the CLI tools, which means spawning processes, managing pipes, and writing temporary files. Or you can use wabt’s C API via CGo, which means your build now requires a C toolchain, cross-compilation gets complicated, and your module cannot be imported by projects that avoid CGo.
wasm-tools is Rust, so Go interop is essentially the same story: subprocess or FFI, both with overhead and friction.
This is not a theoretical complaint. If you are writing a Go compiler that targets WebAssembly, a test framework that generates Wasm modules to exercise a runtime, or a static analysis tool for Wasm binaries, you want to do this in-process without shelling out. You want to pass data structures around, not files. You want go test ./... to work without requiring separately installed native tools.
The zero-dependency, pure-Go requirement is not just aesthetics. It means go install github.com/eliben/watgo/cmd/watgo@latest works on any machine with Go installed, no C compiler needed. It means you can add github.com/eliben/watgo to your go.mod and the build works in CI, on any platform Go supports, without further setup.
The WAT Format Is More Interesting Than It Looks
WAT uses s-expressions, which look like Lisp, but the format has genuine complexity beneath the surface. A simple function looks like this:
(module
(func (export "add") (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add))
But WAT also supports a folded (nested) instruction syntax, which rewrites the same function as:
(module
(func (export "add") (param $a i32) (param $b i32) (result i32)
(i32.add (local.get $a) (local.get $b))))
Both are valid. A conformant parser needs to handle both forms, unfold them into the same linear instruction sequence, and resolve all named references, function names, local names, label names, into the integer indices the binary format expects.
Control flow adds more complexity. block, loop, and if instructions create label scopes, and br and br_table reference those labels by either name or depth index. Getting name resolution right through nested control flow is where naive parsers tend to fail. Then there is type canonicalization: inline type definitions like (param i32 i64) (result i32) must be matched against the module’s type section, either reusing an existing type or creating a new one. The binary format deduplicates types, so the encoder must handle this correctly.
A parser that only handles simple cases is not useful. Writing a complete, spec-compliant WAT parser in pure Go is the actual work here, and it is substantial.
wasmir: The Semantic Representation
The core of watgo is wasmir, a Go package that semantically represents a WebAssembly module. This is where the library’s value as a programmable tool lives.
A Wasm module consists of several sections: types, imports, functions, tables, memories, globals, exports, elements, data, and code. wasmir represents all of these as Go structs you can inspect and manipulate. After parsing a WAT file, you have a wasmir.Module with populated slices of functions, imports, exports, globals, and so on.
This means you can enumerate all exported functions and their signatures, check what imports a module requires before instantiation, or count how many distinct type signatures a module uses, all as ordinary Go code operating on ordinary Go values:
module, err := watgo.ParseString(wasmText)
if err != nil {
log.Fatal(err)
}
for _, export := range module.Exports {
if export.Kind == wasmir.ExportKindFunc {
fn := module.Functions[export.Index]
fmt.Printf("export %q: %v\n", export.Name, fn.Type)
}
}
Without a semantic IR, you are either parsing the binary format yourself (tedious and error-prone given the LEB128 encoding and section structure) or grepping through text output from a CLI tool. With wasmir, you write Go code against a structured representation.
The same IR is the output of the decoder, so you can load an existing .wasm binary and inspect it with the same API. The round-trip from binary to wasmir and back should be lossless for all semantically significant content.
What Validation Actually Checks
The validation step deserves attention. WebAssembly has a formal type system, and the spec defines precise rules for what constitutes a well-formed module. The validator checks things including: all type indices are within bounds; function bodies are type-correct in that every instruction produces and consumes the right stack types; memory and table accesses reference valid indices; control flow is structurally balanced with every block, loop, and if having a matching end; and export names are unique.
The stack-based type checking for function bodies is non-trivial to implement correctly. It requires tracking the operand stack through every instruction, handling the polymorphic stack states that occur after unconditional branches (br, return, unreachable), and threading type signatures through nested block and loop instructions.
Having a validator that runs against the spec gives you confidence that a module you are generating programmatically is actually correct before you hand it to a runtime. Runtimes validate on load, but catching errors at generation time, with access to the full module structure and Go stack traces pointing at your generator code, is far more useful than debugging a runtime rejection with an opaque error message.
Fitting into the Go Wasm Ecosystem
The Go Wasm ecosystem has been quietly maturing. Wazero established that a pure-Go, zero-dependency Wasm runtime was viable and production-ready. TinyGo compiles Go to Wasm for constrained environments. The standard library has supported GOOS=wasip1 since Go 1.21.
watgo fits into this picture as the missing tooling layer. If you are using wazero as your runtime, you now have a complete pure-Go stack: generate or load a Wasm module with watgo, validate it, and execute it with wazero, without any native dependencies in the chain. go build just works, end to end.
For language implementers targeting WebAssembly, the combination is particularly compelling. You write your compiler in Go, emit wasmir structures directly by constructing the Go types rather than generating text, encode to binary with watgo, and run with wazero. The entire pipeline stays in Go, tests run with go test, and the build requires nothing beyond the Go toolchain.
This mirrors what wabt originally did for the C++ ecosystem and what the wasmparser and wasm-encoder crates do for Rust: provide the primitives so that higher-level tooling can be built without reinventing WAT parsing and binary encoding from scratch.
Scope and Caveats
It is worth being clear about what watgo is and is not. It covers the core WebAssembly specification, not the full surface of proposals that wasm-tools handles. The component model, GC proposal, exception handling, and various WASI interface types are a separate layer of complexity. For most Go developers working with Wasm, that scope is correct. The component model matters if you are deep in the Bytecode Alliance ecosystem; for writing a language compiler, building a test harness for a WASI runtime, or doing static analysis on modules, the core spec is what you need.
The CLI aims for compatibility with wasm-tools, which is a pragmatic choice. Existing scripts that invoke wasm-tools can point at watgo instead, and users familiar with wasm-tools get a recognizable interface without learning new flags.
Bendersky’s blog at eli.thegreenplace.net has covered WebAssembly in depth over the years, including his wasm-wat-samples repository of annotated example programs. Projects that come out of that kind of sustained engagement with a spec tend to handle the edge cases correctly, because the author has already worked through them in published writing. A library this close to a specification is only as trustworthy as the care taken to implement the spec accurately, and that track record matters.