Skip to content

Method 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.

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:

Terminal window
wails3 generate bindings

4. Use in JavaScript:

import { Greet } from './bindings/myapp/greetservice'
const message = await Greet("World")
console.log(message) // "Hello, World!"

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

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)
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.

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)),
},
})
Terminal window
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:

  • Directoryfrontend/bindings
    • Directorymyapp
      • calculatorservice.js
      • counterservice.js
      • databaseservice.js
      • index.js
Terminal window
wails3 generate bindings -ts

Generates .ts files with full TypeScript types.

Terminal window
wails3 generate bindings -d ./src/bindings
Terminal window
wails3 dev

Automatically regenerates bindings when Go code changes.

Generated binding:

frontend/bindings/myapp/calculatorservice.js
/**
* @param {number} a
* @param {number} b
* @returns {Promise<number>}
*/
export function Add(a, b) {
return window.wails.Call('CalculatorService.Add', a, b)
}

Usage:

import { Add, Subtract, Multiply, Divide } from './bindings/myapp/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"
}

Generated binding:

frontend/bindings/myapp/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/myapp/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

Generated index:

frontend/bindings/myapp/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)
Go TypeJavaScript/TypeScript
stringstring
boolboolean
int, int8, int16, int32, int64number
uint, uint8, uint16, uint32, uint64number
float32, float64number
bytenumber
runenumber
Go TypeJavaScript/TypeScript
[]TT[]
[N]TT[]
map[string]TRecord<string, T>
map[K]VMap<K, V>
structclass (with fields)
time.TimeDate
*TT (pointers transparent)
interface{}any
errorException (thrown)

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()
}
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
}
import { GetUser } from './bindings/myapp/databaseservice'
try {
const user = await GetUser(123)
console.log("User:", user)
} catch (error) {
console.error("Error:", error)
// Error: "user 123 not found"
}

Error types:

  • Go error → JavaScript exception
  • Error message preserved
  • Stack trace available

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

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

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/myapp/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()
  • 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 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

Questions? Ask in Discord or check the binding examples.