gowasm-bindgen for TypeScript Developers#
You know TypeScript. You’re curious about Go. Here’s how to run Go code in your browser with full type safety.
Why Go in the Browser?#
- Performance: Go compiles to WebAssembly, running at near-native speed
- Shared logic: Use the same code on your backend and frontend
- Type safety: With gowasm-bindgen, your Go functions get proper TypeScript types
- Non-blocking: Web Worker mode keeps your UI responsive
What You Need to Know#
Your Go teammate writes normal Go functions with standard types. gowasm-bindgen reads the source code and generates TypeScript bindings automatically. You import the generated TypeScript class. That’s it.
The files you care about:
| File | What it is |
|---|---|
wasm.wasm | The compiled Go code (runs in Web Worker) |
worker.js | Generated Web Worker script (loads and runs WASM) |
go-wasm.ts | Generated TypeScript class (e.g., GoWasm from wasm/ directory) |
wasm_exec.js | Go runtime (copied from compiler) |
All files are generated by gowasm-bindgen into the generated/ directory by default.
Using the Generated Class API#
1. Import and initialize#
The class name is derived from the source directory (e.g., wasm/ → GoWasm). Override with --class-name.
import { GoWasm } from './generated/go-wasm';
// Initialize with the Web Worker URL
const wasm = await GoWasm.init('./worker.js');2. Call Go functions with full type checking#
// TypeScript knows: greet(name: string): Promise<string>
const greeting = await wasm.greet("World");
console.log(greeting); // "Hello, World!"
// TypeScript knows: calculate(a: number, b: number, op: string): Promise<number>
const sum = await wasm.calculate(5, 3, "add");
console.log(sum); // 8
// TypeScript knows the return type is Promise<{ displayName: string, status: string }>
const user = await wasm.formatUser("Alice", 30, true);
console.log(user.displayName); // "Alice (30)"
console.log(user.status); // "active"
3. Handle errors with try/catch#
Go functions that return (T, error) automatically throw in TypeScript:
// Go: func Divide(a, b int) (int, error)
try {
const result = await wasm.divide(10, 0);
} catch (e) {
console.error(e.message); // "division by zero"
}4. Clean up when done#
// Terminate the Web Worker when you're done
wasm.terminate();5. TypeScript catches your mistakes#
// Error: Argument of type 'number' is not assignable to parameter of type 'string'
await wasm.greet(42);
// Error: Expected 3 arguments, but got 2
await wasm.calculate(5, 3);
// Error: Property 'wrongField' does not exist
const user = await wasm.formatUser("Bob", 25, false);
console.log(user.wrongField);Complete Example#
See the examples/simple/src/app.ts for a working TypeScript application that uses the generated class API.
import { GoWasm } from './generated/go-wasm';
async function main(): Promise<void> {
// Initialize the WASM module in a Web Worker
const wasm = await GoWasm.init('./worker.js');
// Call Go functions with full type safety
const greeting = await wasm.greet("TypeScript");
console.log(greeting); // "Hello, TypeScript!"
const result = await wasm.calculate(10, 5, "add");
console.log(result); // 15
// Clean up when done
wasm.terminate();
}
void main();Troubleshooting#
“Cannot find module”#
Make sure you’ve generated the TypeScript client:
gowasm-bindgen wasm/main.goThis creates all files in generated/ by default. The TypeScript file name is derived from the class name (e.g., GoWasm → go-wasm.ts).
Return type is any instead of a specific type#
This shouldn’t happen with the new source-based generation. gowasm-bindgen infers types directly from Go function signatures:
// Go code - types are inferred from the signature
func Greet(name string) string {
return "Hello, " + name + "!"
}
// → TypeScript: greet(name: string): Promise<string>If you see any, the Go function might be using interface{}. Ask your Go teammate to use concrete types.
Worker fails to load / “Failed to construct ‘Worker’”#
- Make sure
worker.jsandwasm.wasmare in the correct location relative to your HTML - Check that your bundler (if using one) is configured to copy these files to your output directory
- Verify the worker URL path is correct (relative to your HTML page, not your TypeScript file)
Want synchronous calls instead of async?#
Use the --mode sync flag to generate a synchronous API that runs on the main thread:
gowasm-bindgen wasm/main.go --mode sync// Sync mode - no await, no Web Worker
const wasm = await GoWasm.init('./wasm.wasm'); // init is still async
const greeting = wasm.greet('World'); // but calls are sync
Note: Sync mode blocks the main thread, which can freeze your UI for long-running operations.
Using in Node.js#
The sync mode init() method accepts either a URL string (for browsers) or a BufferSource (for Node.js):
import { readFileSync } from 'fs';
import { GoWasm } from './generated/go-wasm.js';
// Load wasm_exec.js (required for Go WASM runtime)
import './generated/wasm_exec.js';
// Pass the WASM bytes directly instead of a URL
const wasmBytes = readFileSync('./generated/wasm.wasm');
const wasm = await GoWasm.init(wasmBytes);
const result = wasm.greet('Node.js');
console.log(result); // "Hello, Node.js!"
This works because the generated init() signature is:
static async init(wasmSource: string | BufferSource): Promise<GoWasm>- Browser: Pass a URL string, uses
fetch()+WebAssembly.instantiateStreaming() - Node.js: Pass a
Buffer/ArrayBuffer/Uint8Array, usesWebAssembly.instantiate()
Project Structure#
A typical project using gowasm-bindgen looks like this:
your-project/
├── wasm/ # All Go code
│ ├── main.go # Go code with normal functions (your teammate writes this)
│ ├── main_test.go # Optional unit tests
│ └── bindings_gen.go # Generated Go WASM bindings (gitignored)
├── src/ # TypeScript source
│ └── app.ts # Your TypeScript code
├── public/ # Static assets
│ └── index.html # Source HTML
└── generated/ # gowasm-bindgen output (gitignored)
├── go-wasm.ts # Generated TypeScript class
├── worker.js # Generated Web Worker
├── wasm_exec.js # Go runtime (copied)
└── wasm.wasm # Compiled WASMCommon Gotchas#
1. Worker Mode Is Async (default)#
By default, gowasm-bindgen generates an async Worker-based API:
// ✅ Correct - await the Promise
const result = await wasm.greet("World");
// ❌ Wrong - forgot await, result is a Promise!
const result = wasm.greet("World");
console.log(result); // Promise { <pending> }
Want sync? Use --mode sync flag for synchronous calls (but this blocks the main thread):
gowasm-bindgen wasm/main.go --mode syncconst wasm = await GoWasm.init('./wasm.wasm');
const result = wasm.greet("World"); // No await - synchronous
2. Always Await init()#
Module initialization is always async, even in sync mode:
// ❌ Wrong - forgot await on init
const wasm = GoWasm.init('./worker.js');
await wasm.greet("World"); // Error: wasm is a Promise!
// ✅ Correct - await init first
const wasm = await GoWasm.init('./worker.js');
await wasm.greet("World"); // Now it works
3. Types Don’t Validate Runtime Data#
Generated types tell TypeScript what to expect, but they don’t enforce it at runtime:
const user = await wasm.formatUser("Alice", 30, true);
// TypeScript thinks: { displayName: string, status: string }
// But if the Go code changes to return { name, active } instead,
// TypeScript won't catch it! The generated types become stale.
Solution: Regenerate types when Go code changes, and keep tests in sync.
4. Working with Binary Data (Typed Arrays)#
Go []byte functions accept and return Uint8Array:
// Byte arrays use efficient bulk copy (~10-100x faster for large data)
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash = await wasm.hashData(data); // Returns Uint8Array
// Other typed arrays also work
const nums = new Int32Array([1, 2, 3, 4, 5]);
const doubled = await wasm.processNumbers(nums); // Returns Int32Array
| Go Type | TypeScript Type |
|---|---|
[]byte, []uint8 | Uint8Array |
[]int8 | Int8Array |
[]int32 | Int32Array |
[]float64 | Float64Array |
func(T, U) | (arg0: T, arg1: U) => void |
5. Void Callbacks#
Functions that take callback parameters work with TypeScript arrow functions. Callbacks work in both worker and sync modes:
// Go: func ForEach(items []string, callback func(string, int))
// Worker mode - async
await wasm.forEach(["a", "b", "c"], (item, index) => {
console.log(`${index}: ${item}`);
});Worker mode (default): Uses fire-and-forget message passing. Go invokes callbacks by posting messages to the main thread. Your UI stays responsive!
Sync mode: Callbacks are invoked directly and synchronously.
Limitations:
- Callbacks must have no return value (void)
- Callbacks are only invoked during the Go function’s execution
- In worker mode, callback errors are logged to console but cannot propagate to Go
- In sync mode, if your callback throws, Go will panic (caught by error boundary)
6. Memory Considerations#
Byte arrays use efficient bulk copy. For other data, consider batching:
// ❌ Slow - copying large data on every call
for (const item of hugeArray) {
await wasm.processItem(item);
}
// ✅ Better - batch if possible or use typed arrays
await wasm.processItems(hugeArray.join(","));
// Or with typed arrays (fastest for numeric data):
const typedArray = new Int32Array(hugeArray);
await wasm.processNumbers(typedArray);7. Debugging WASM Functions#
TypeScript debuggers can’t step into WASM code. Use logging:
// Log before/after the WASM call
console.log("Calling greet with:", name);
const result = await wasm.greet(name);
console.log("Result:", result);In Go code, you can log to the browser console:
js.Global().Get("console").Call("log", "Debug from Go:", value)8. The void Operator Pattern#
You’ll see void in our examples:
void main();This is NOT “ignore the result”—it explicitly marks a Promise as intentionally not awaited. ESLint requires this to prevent accidental fire-and-forget bugs.
TypeScript Configuration#
The generated TypeScript file is just a regular TypeScript file you can import directly. No special tsconfig needed beyond standard module resolution.
Next Steps#
- Check out the examples/simple/ directory for a complete working demo
- Run
make servein the examples/simple directory, then open http://localhost:8080 - Read for-go-devs.md if you want to understand how the Go side works