Back to Wails

Method Bindings

docs/src/content/docs/features/bindings/methods.mdx

2.13.017.8 KB
Original Source

import { FileTree, Card, CardGrid } from "@astrojs/starlight/components";

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:

go
type GreetService struct{}

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

2. Register service:

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

3. Generate bindings:

bash
wails3 generate bindings

4. Use in JavaScript:

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

go
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:

go
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

go
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

go
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:

go
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:

<FileTree> - frontend/bindings - myapp - calculatorservice.js - counterservice.js - databaseservice.js - index.js </FileTree>

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:

javascript
// 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:

javascript
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:

typescript
// 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:

typescript
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:

javascript
// 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:

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

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

Type Mapping

Primitive Types

Go TypeJavaScript/TypeScript
stringstring
boolboolean
int, int8, int16, int32, int64number
uint, uint8, uint16, uint32, uint64number
float32, float64number
bytenumber
runenumber

Complex Types

Go TypeJavaScript/TypeScriptNotes
[]TT[]-
[N]TT[]-
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
[]bytestringbase64-encoded
structclass / interfacewith fields
time.Timeanyserialized as RFC3339Nano string in runtime
*TT | nullpointer means nullable
any / interface{}any-
errorany / ExceptionException 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:

go
// ❌ 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

go
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:

javascript
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 typeThrown when
TypeErrorThe call has the wrong number of arguments, or an argument cannot be converted to the Go type
RuntimeErrorThe method returned an error, or panicked while running
ErrorAny 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:

javascript
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
    }
}

:::note In earlier versions of the runtime, every failed call rejected with a plain Error whose message contained the raw JSON of the underlying error. :::

Structured Error Data

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

go
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
}
javascript
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:

go
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:

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

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

✅ Cache results:

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

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

✅ Use events for streaming:

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

Complete Example

Go:

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:

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

<CardGrid> <Card title="Services" icon="puzzle"> Deep dive into the service system.
[Learn More →](/features/bindings/services)
</Card> <Card title="Models" icon="document"> Bind complex data structures.
[Learn More →](/features/bindings/models)
</Card> <Card title="Go-Frontend Bridge" icon="rocket"> Understand the bridge mechanism.
[Learn More →](/concepts/bridge)
</Card> <Card title="Events" icon="star"> Use events for pub/sub communication.
[Learn More →](/features/events/system)
</Card> </CardGrid>

Questions? Ask in Discord or check the binding examples.