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/changeme/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/<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"
}

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

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)
Go TypeJavaScript/TypeScript
stringstring
boolboolean
int, int8, int16, int32, int64number
uint, uint8, uint16, uint32, uint64number
float32, float64number
bytenumber
runenumber
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

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
}

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

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

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.

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