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:

FileWhat it is
wasm.wasmThe compiled Go code (runs in Web Worker)
worker.jsGenerated Web Worker script (loads and runs WASM)
go-wasm.tsGenerated TypeScript class (e.g., GoWasm from wasm/ directory)
wasm_exec.jsGo 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.go

This creates all files in generated/ by default. The TypeScript file name is derived from the class name (e.g., GoWasmgo-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.js and wasm.wasm are 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, uses WebAssembly.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 WASM

Common 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 sync
const 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 TypeTypeScript Type
[]byte, []uint8Uint8Array
[]int8Int8Array
[]int32Int32Array
[]float64Float64Array
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 serve in the examples/simple directory, then open http://localhost:8080
  • Read for-go-devs.md if you want to understand how the Go side works