The scripting gap
You are building a data processor that needs to apply user-defined transformation rules. Go handles the heavy lifting, but users want to tweak the logic without waiting for a new binary release. You need a scripting layer that runs inside your application, and JavaScript is the language your users already know.
What goja actually is
goja is a JavaScript engine written entirely in Go. It compiles directly into your binary. There are no external binaries, no shared libraries, and no cgo dependency. The engine runs on every platform your Go code supports. Think of goja like a universal travel adapter. Your Go program is the wall outlet with a fixed voltage and plug shape. JavaScript is the device you want to power. The adapter sits between them, translating the signals so both sides operate safely without rewiring either system. goja handles the translation, memory management, and execution while your Go code stays in control.
Embed JS without the browser baggage. Your binary stays portable.
The minimal setup
Here is the simplest setup: spawn a virtual machine, expose a Go function, run a script, and read the result.
package main
import (
"fmt"
"github.com/dop251/goja"
)
// main initializes the VM and runs a basic script
func main() {
// Allocate an isolated runtime; each VM gets its own global scope
vm := goja.New()
// Bind a Go function to the JS global namespace
// The wrapper automatically converts Go arguments to JS types
vm.Set("greet", func(name string) string {
return "Hello, " + name
})
// Parse and execute the script string
// Returns a goja.Value representing the final expression
result, err := vm.RunString(`greet("Goja")`)
if err != nil {
fmt.Println("Runtime error:", err)
return
}
// Convert the internal JS value back to a native Go type
fmt.Println(result.Export())
}
goja.New() allocates a fresh virtual machine. The VM holds the global scope, loaded scripts, and any Go values you attach. vm.Set binds a Go value to a name in the JavaScript global scope. When you pass a Go function, goja wraps it so JavaScript can call it. RunString parses and executes the JavaScript code. It returns a goja.Value, which is the engine's internal representation of a JavaScript value. You must call .Export() to convert that value back to a Go type.
The bridge works both directions. Go types become JavaScript values when you call Set. JavaScript values become Go types when you call Export. The conversion follows a strict mapping. Go structs map to JavaScript objects with properties. Slices become arrays. Maps become objects. Functions become callable wrappers. The reverse happens with Export. You ask for a type, and goja tries to coerce. If the JavaScript value is a string and you ask for an integer, you get a zero value and a type assertion failure. This mismatch is the most common source of bugs.
The bridge is strong but strict. Verify types before you cast.
How the type bridge works
Go is statically typed. JavaScript is dynamically typed. goja sits in the middle. When you call vm.Set, goja inspects the Go value. If it is a struct, it creates a JS object. If it is a slice, it creates an array. If it is a function, it creates a callable wrapper. The reverse happens with Export. You ask for a type, and goja tries to coerce. If the JS value is a string and you ask for an int, you get a zero value and a type assertion failure. This mismatch is the most common source of bugs.
Go structs map to JS objects with properties. Fields become accessible keys.
// Go structs map to JS objects; fields become properties
type Config struct {
Timeout int
Debug bool
}
vm := goja.New()
// Set the struct; JS can read Config.Timeout
vm.Set("config", Config{Timeout: 5, Debug: true})
// JS can access properties directly
_, err := vm.RunString(`console.log(config.Timeout)`)
Slices become arrays. Maps become objects. Functions become functions. The receiver name convention applies here too. If you expose a method, the receiver is usually one or two letters. goja does not care about the name, but your Go code should follow the style. (c *Config) String() is standard. (this *Config) String() is not. Public names start with a capital letter. Private start lowercase. No keywords like public or private. If you want a field hidden from JavaScript, make it lowercase. goja respects Go's visibility rules.
The bridge is strong but strict. Verify types before you cast.
Realistic usage: user scripts
A common pattern is letting users write a script that processes data. You define the interface, the user fills in the logic. Here is how to load a script, validate it, and call it from Go.
// Define the script; users write this logic
script := `
function transform(item) {
return item.toUpperCase() + "_DONE";
}
`
// Create VM and load the script
vm := goja.New()
_, err := vm.RunString(script)
if err != nil {
// Handle syntax errors or runtime exceptions
panic(err)
}
The script defines a global function. You retrieve it by name and assert its type.
// Retrieve the function by name from the VM
fnVal := vm.Get("transform")
// Export and assert the type to a Go function signature
fn, ok := fnVal.Export().(func(string) string)
if !ok {
panic("transform is not a function or has wrong signature")
}
// Call the function directly from Go
result := fn("hello")
fmt.Println(result)
vm.Get returns a goja.Value. You call .Export() to get the underlying Go interface. Then you use a type assertion to check if it matches the expected signature. If the user wrote function transform() { return 1; }, the assertion fails. You catch the error and report it. This keeps your Go code safe from malformed scripts.
In production code, return the error instead of panicking. if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Wrap the error with fmt.Errorf("loading script: %w", err) to add context. If your Go function needs cancellation, accept context.Context as the first parameter. context.Context always goes as the first parameter, conventionally named ctx. When goja calls the function, it passes undefined for the context unless you explicitly wrap the invocation. Design your API so the script author does not need to worry about plumbing.
Validate the script interface early. A missing function should fail at load time, not runtime.
Pitfalls and runtime behavior
goja.Runtime is not safe for concurrent use. If two goroutines call RunString on the same VM, you will hit a race condition. The runtime might crash with fatal error: concurrent map writes or produce corrupted results. Create a new VM per goroutine, or use a mutex if you must share. Creating new VMs is usually cheap enough that isolation is the better choice. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. The same principle applies to VMs: if you spawn a VM and forget to drop the reference, the heap grows until the process dies.
Type assertions can panic if you are not careful. If you call .Export().(string) on a JavaScript number, the program panics. Always use the two-value form: val, ok := export.(string). If ok is false, handle the mismatch gracefully. The compiler will not catch this. You get a runtime panic like interface conversion: goja.Value is not string. The compiler rejects this with an undefined-variable error if you misspell a global, but type mismatches only surface at runtime.
Memory leaks happen when the VM holds references to large Go objects. If you expose a huge slice and the script stores it in a global variable, the slice stays in memory as long as the VM lives. Call vm.Set("name", nil) to clear references, or create a new VM for each script execution. The worst VM bug is the one that silently holds memory.
One VM per goroutine. Sharing a runtime is a race waiting to happen.
When to use goja
Use goja when you need a full JavaScript engine embedded in a single Go binary. Use text/template when you only need simple variable interpolation and basic control flow. Use a plugin system with net/http or gRPC when you need to isolate untrusted code in separate processes. Use a configuration file format like YAML or JSON when the logic is static and does not require computation. Use goja when users need to write dynamic logic like data transformations or event handlers without recompiling your app.
Pick the tool that matches the complexity. Don't embed a VM to replace a config file.