Services
Deep dive into the service system.
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:
wails3 generate bindings4. 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:
(value, error)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)), },})wails3 generate bindingsOutput:
INFO 347 Packages, 3 Services, 12 Methods, 0 Enums, 0 Models in 1.98sINFO Output directory: /myproject/frontend/bindingsGenerated structure:
wails3 generate bindings -tsGenerates .ts files with full TypeScript types.
wails3 generate bindings -d ./src/bindingswails3 devAutomatically 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 callsconst sum = await Add(5, 3) // 8const diff = await Subtract(10, 4) // 6const product = await Multiply(7, 6) // 42
// Error handlingtry { const result = await Divide(10, 0)} catch (error) { console.error("Error:", error) // "division by zero"}Generated binding:
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:
Generated index:
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 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 |
| 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 |
These types cannot be passed across the bridge:
chan T (channels)func() (functions)interface{})Workaround: Use IDs or handles:
// ❌ Can't return file handlefunc OpenFile(path string) (*os.File, error)
// ✅ Return file ID insteadvar 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 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 abovemessage: the message of the Go errorcause: 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 ↓ ↓ ↓ ↓ ↓ <0.1ms <0.1ms [varies] <0.1ms <0.1msCompared to alternatives:
✅ Batch operations:
// ❌ Slow: N callsfor (const item of items) { await ProcessItem(item)}
// ✅ Fast: 1 callawait ProcessItems(items)✅ Cache results:
// ❌ Repeated callsconst config1 = await GetConfig()const config2 = await GetConfig()
// ✅ Cacheconst 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()Services
Deep dive into the service system.
Models
Bind complex data structures.
Go-Frontend Bridge
Understand the bridge mechanism.
Events
Use events for pub/sub communication.
Questions? Ask in Discord or check the binding examples.