Method Bindings

Type-Safe Go-JavaScript Bindings

Wails automatically generates type-safe JavaScript/TypeScript bindings for your Go methods. Write Go code, run one command, and get fully-typed frontend functions with no HTTP overhead, no manual work, and zero boilerplate.

Quick Start

1. Write Go service:

type GreetService struct{}

func (g *GreetService) Greet(name string) string {
    return "Hello, " + name + "!"
}

2. Register service:

app := application.New(application.Options{
    Services: []application.Service{
        application.NewService(&GreetService{}),
    },
})

3. Generate bindings:

bash
wails3 generate bindings

4. Use in JavaScript:

import { Greet } from './bindings/changeme/greetservice'

const message = await Greet("World")
console.log(message)  // "Hello, World!"

That’s it! Type-safe Go-to-JavaScript calls.

Creating Services

Basic Service

package main

import "github.com/wailsapp/wails/v3/pkg/application"

type CalculatorService struct{}

func (c *CalculatorService) Add(a, b int) int {
    return a + b
}

func (c *CalculatorService) Subtract(a, b int) int {
    return a - b
}

func (c *CalculatorService) Multiply(a, b int) int {
    return a * b
}

func (c *CalculatorService) Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

Register:

app := application.New(application.Options{
    Services: []application.Service{
        application.NewService(&CalculatorService{}),
    },
})

Key points:

  • Only exported methods (PascalCase) are bound
  • Methods can return values or (value, error)
  • Services are singletons (one instance per application)

Service with State

type CounterService struct {
    count int
    mu    sync.Mutex
}

func (c *CounterService) Increment() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
    return c.count
}

func (c *CounterService) Decrement() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count--
    return c.count
}

func (c *CounterService) GetCount() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.count
}

func (c *CounterService) Reset() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count = 0
}

Important: Services are shared across all windows. Use mutexes for thread safety.

Service with Dependencies

type DatabaseService struct {
    db *sql.DB
}

func NewDatabaseService(db *sql.DB) *DatabaseService {
    return &DatabaseService{db: db}
}

func (d *DatabaseService) GetUser(id int) (*User, error) {
    var user User
    err := d.db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user)
    return &user, err
}

Register with dependencies:

db, _ := sql.Open("sqlite3", "app.db")

app := application.New(application.Options{
    Services: []application.Service{
        application.NewService(NewDatabaseService(db)),
    },
})

Generating Bindings

Basic Generation

bash
wails3 generate bindings

Output:

INFO  347 Packages, 3 Services, 12 Methods, 0 Enums, 0 Models in 1.98s
INFO  Output directory: /myproject/frontend/bindings

Generated structure:

  • frontend/bindings
    • myapp
      • calculatorservice.js
      • counterservice.js
      • databaseservice.js
      • index.js

TypeScript Generation

bash
wails3 generate bindings -ts

Generates .ts files with full TypeScript types.

Custom Output Directory

bash
wails3 generate bindings -d ./src/bindings

Watch Mode (Development)

bash
wails3 dev

Automatically regenerates bindings when Go code changes.

Using Bindings

JavaScript

Generated binding:

// frontend/bindings/<full-go-import-path>/calculatorservice.js
// (Real generated output — imports $Call from /wails/runtime.js and calls $Call.ByID
// with a numeric method ID. Generate with `wails3 generate bindings -names` to get
// $Call.ByName("<package>.<Struct>.<Method>", ...) instead.)
import { Call as $Call, Create as $Create } from "/wails/runtime.js";

/**
 * @param {number} $0
 * @param {number} $1
 * @returns {Promise<number>}
 */
export function Add($0, $1) {
    return $Call.ByID(1234567890, $0, $1); // numeric ID assigned by the generator
}

Usage:

import { Add, Subtract, Multiply, Divide } from './bindings/changeme/calculatorservice'

// Simple calls
const sum = await Add(5, 3)        // 8
const diff = await Subtract(10, 4)  // 6
const product = await Multiply(7, 6) // 42

// Error handling
try {
    const result = await Divide(10, 0)
} catch (error) {
    console.error("Error:", error)  // "division by zero"
}

TypeScript

Generated binding:

// frontend/bindings/changeme/calculatorservice.ts

export function Add(a: number, b: number): Promise<number>
export function Subtract(a: number, b: number): Promise<number>
export function Multiply(a: number, b: number): Promise<number>
export function Divide(a: number, b: number): Promise<number>

Usage:

import { Add, Divide } from './bindings/changeme/calculatorservice'

const sum: number = await Add(5, 3)

try {
    const result = await Divide(10, 0)
} catch (error: unknown) {
    if (error instanceof Error) {
        console.error(error.message)
    }
}

Benefits:

  • Full type checking
  • IDE autocomplete
  • Compile-time errors
  • Better refactoring

Index Files

Generated index:

// frontend/bindings/changeme/index.js

export * as CalculatorService from './calculatorservice.js'
export * as CounterService from './counterservice.js'
export * as DatabaseService from './databaseservice.js'

Simplified imports:

import { CalculatorService } from './bindings/myapp'

const sum = await CalculatorService.Add(5, 3)

Type Mapping

Primitive Types

Go Type JavaScript/TypeScript
string string
bool boolean
int, int8, int16, int32, int64 number
uint, uint8, uint16, uint32, uint64 number
float32, float64 number
byte number
rune number

Complex Types

Go Type JavaScript/TypeScript Notes
[]T T[] -
[N]T T[] -
map[string]T { [_: string]: T } string-keyed map
map[K]V { [_ in K]?: V } non-string K is rendered as a mapped type, not a JS Map
[]byte string base64-encoded
struct class / interface with fields
time.Time any serialized as RFC3339Nano string in runtime
*T T | null pointer means nullable
any / interface{} any -
error any / Exception Exception if as return value else any

Unsupported Types

These types cannot be passed across the bridge:

  • chan T (channels)
  • func() (functions)
  • Complex interfaces (except interface{})
  • Unexported fields (lowercase)

Workaround: Use IDs or handles:

// ❌ Can't return file handle
func OpenFile(path string) (*os.File, error)

// ✅ Return file ID instead
var files = make(map[string]*os.File)

func OpenFile(path string) (string, error) {
    file, err := os.Open(path)
    if err != nil {
        return "", err
    }
    id := generateID()
    files[id] = file
    return id, nil
}

func ReadFile(id string) ([]byte, error) {
    file := files[id]
    return io.ReadAll(file)
}

func CloseFile(id string) error {
    file := files[id]
    delete(files, id)
    return file.Close()
}

Error Handling

Go Side

func (d *DatabaseService) GetUser(id int) (*User, error) {
    if id <= 0 {
        return nil, errors.New("invalid user ID")
    }
    
    var user User
    err := d.db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user)
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("user %d not found", id)
    }
    if err != nil {
        return nil, fmt.Errorf("database error: %w", err)
    }
    
    return &user, nil
}

JavaScript Side

When a bound method fails, the returned promise rejects with a JavaScript Error object:

import { GetUser } from './bindings/changeme/databaseservice'

try {
    const user = await GetUser(123)
    console.log("User:", user)
} catch (error) {
    console.error(error.message)
    // "user 123 not found"
}

The runtime throws a different error type depending on what went wrong:

Error type Thrown when
TypeError The call has the wrong number of arguments, or an argument cannot be converted to the Go type
RuntimeError The method returned an error, or panicked while running
Error Any other failure, for example a call to a method that does not exist

Every error provides:

  • name: the error type from the table above
  • message: the message of the Go error
  • cause: the Go error serialised as JSON, where available. If the method returned multiple errors, cause is an array with one entry per error.

The RuntimeError class is exported by the @wailsio/runtime package, so you can distinguish errors returned by your Go code from other failures:

import { Call } from '@wailsio/runtime'
import { GetUser } from './bindings/changeme/databaseservice'

try {
    const user = await GetUser(123)
} catch (error) {
    if (error instanceof Call.RuntimeError) {
        // GetUser returned an error
    } else {
        // The call itself failed
    }
}

Structured Error Data

Returning a custom error type from Go makes its JSON form available on the cause property of the thrown error:

type ValidationError struct {
    Field  string `json:"field"`
    Reason string `json:"reason"`
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Reason)
}

func (s *UserService) UpdateEmail(id int, email string) error {
    if !strings.Contains(email, "@") {
        return &ValidationError{Field: "email", Reason: "invalid email address"}
    }
    // ...
    return nil
}
import { UpdateEmail } from './bindings/changeme/userservice'

try {
    await UpdateEmail(1, "not-an-email")
} catch (error) {
    console.log(error.message)  // "email: invalid email address"
    console.log(error.cause)    // { field: "email", reason: "invalid email address" }
}

Errors are serialised with the standard encoding/json package, so only exported fields are included. Errors created with errors.New or fmt.Errorf have no exported fields and serialise as an empty object.

For full control over how errors are serialised, provide a MarshalError function in the service options:

app := application.New(application.Options{
    Services: []application.Service{
        application.NewServiceWithOptions(&UserService{}, application.ServiceOptions{
            MarshalError: func(err error) []byte {
                var validationErr *ValidationError
                if errors.As(err, &validationErr) {
                    data, _ := json.Marshal(map[string]string{
                        "type":  "validation",
                        "field": validationErr.Field,
                    })
                    return data
                }
                return nil // fall back to the default serialisation
            },
        }),
    },
})

MarshalError must return valid JSON, or nil to fall back to the default serialisation.

Performance

Call Overhead

Typical call: <1ms

JavaScript → Bridge → Go → Bridge → JavaScript
    ↓           ↓       ↓       ↓           ↓
  &lt;0.1ms    &lt;0.1ms  [varies] &lt;0.1ms    &lt;0.1ms

Compared to alternatives:

  • HTTP/REST: 5-50ms
  • IPC: 1-10ms
  • Wails: <1ms

Optimisation Tips

✅ Batch operations:

// ❌ Slow: N calls
for (const item of items) {
    await ProcessItem(item)
}

// ✅ Fast: 1 call
await ProcessItems(items)

✅ Cache results:

// ❌ Repeated calls
const config1 = await GetConfig()
const config2 = await GetConfig()

// ✅ Cache
const config = await GetConfig()
// Use config multiple times

✅ Use events for streaming:

func ProcessLargeFile(path string) error {
    // Emit progress events
    for line := range lines {
        app.Event.Emit("progress", line)
    }
    return nil
}

Complete Example

Go:

package main

import (
    "fmt"
    "github.com/wailsapp/wails/v3/pkg/application"
)

type TodoService struct {
    todos []Todo
}

type Todo struct {
    ID        int    `json:"id"`
    Title     string `json:"title"`
    Completed bool   `json:"completed"`
}

func (t *TodoService) GetAll() []Todo {
    return t.todos
}

func (t *TodoService) Add(title string) Todo {
    todo := Todo{
        ID:        len(t.todos) + 1,
        Title:     title,
        Completed: false,
    }
    t.todos = append(t.todos, todo)
    return todo
}

func (t *TodoService) Toggle(id int) error {
    for i := range t.todos {
        if t.todos[i].ID == id {
            t.todos[i].Completed = !t.todos[i].Completed
            return nil
        }
    }
    return fmt.Errorf("todo %d not found", id)
}

func (t *TodoService) Delete(id int) error {
    for i := range t.todos {
        if t.todos[i].ID == id {
            t.todos = append(t.todos[:i], t.todos[i+1:]...)
            return nil
        }
    }
    return fmt.Errorf("todo %d not found", id)
}

func main() {
    app := application.New(application.Options{
        Services: []application.Service{
            application.NewService(&TodoService{}),
        },
    })
    
    app.Window.New()
    app.Run()
}

JavaScript:

import { GetAll, Add, Toggle, Delete } from './bindings/changeme/todoservice'

class TodoApp {
    async loadTodos() {
        const todos = await GetAll()
        this.renderTodos(todos)
    }
    
    async addTodo(title) {
        try {
            const todo = await Add(title)
            this.loadTodos()
        } catch (error) {
            console.error("Failed to add todo:", error)
        }
    }
    
    async toggleTodo(id) {
        try {
            await Toggle(id)
            this.loadTodos()
        } catch (error) {
            console.error("Failed to toggle todo:", error)
        }
    }
    
    async deleteTodo(id) {
        try {
            await Delete(id)
            this.loadTodos()
        } catch (error) {
            console.error("Failed to delete todo:", error)
        }
    }
    
    renderTodos(todos) {
        const list = document.getElementById('todo-list')
        list.innerHTML = todos.map(todo => `
            <div class="todo ${todo.Completed ? 'completed' : ''}">
                <input type="checkbox" 
                       ${todo.Completed ? 'checked' : ''}
                       onchange="app.toggleTodo(${todo.ID})">
                <span>${todo.Title}</span>
                <button onclick="app.deleteTodo(${todo.ID})">Delete</button>
            </div>
        `).join('')
    }
}

const app = new TodoApp()
app.loadTodos()

Best Practices

✅ Do

  • Keep methods simple - Single responsibility
  • Return errors - Don’t panic
  • Use thread-safe state - Mutexes for shared data
  • Batch operations - Reduce bridge calls
  • Cache on Go side - Avoid repeated work
  • Document methods - Comments become JSDoc

❌ Don’t

  • Don’t block - Use goroutines for long operations
  • Don’t return channels - Use events instead
  • Don’t return functions - Not supported
  • Don’t ignore errors - Always handle them
  • Don’t use unexported fields - Won’t be bound

Next Steps

Services

Deep dive into the service system.

Learn More →

Models

Bind complex data structures.

Learn More →

Go-Frontend Bridge

Understand the bridge mechanism.

Learn More →

Events

Use events for pub/sub communication.

Learn More →


Questions? Ask in Discord or check the binding examples.

Edit page

Last updated: