Introduction
zua is a Zig library for embedding Lua. You write Zig, Lua calls it.
The Lua C API works, but it requires you to think about the stack constantly. You push arguments, check types, pop return values, and make sure nothing blows up if the indices are off. It is doable but it makes every binding function look the same, and that sameness is not helping you, it is just noise.
zua takes a different approach. You write typed Zig functions and register them. The argument decoding and return value encoding happen automatically at the boundary. Your functions do not know they are being called from Lua.
fn add(a: i32, b: i32) Result(i32) {
return Result(i32).ok(a + b);
}
globals.setFn("add", ZuaFn.pure(add, .{
.parse_error = "add expects (i32, i32)",
}));
print(add(1, 2)) -- 3
print(add("oops")) -- error: add expects (i32, i32)
That is the whole idea. The comptime machinery handles the boundary so the Zig side stays clean.
zua uses comptime heavily. Type-based dispatch, automatic struct decoding, metatable generation from declared methods, all of it happens at compile time with no runtime overhead. If you like libraries that do the hard work for you but stay explicit at the call site, this should feel natural.
A few things zua does not do: it does not wrap every Lua feature, it does not try to be a general-purpose Lua embedding toolkit. It adds things when they are needed for real projects. If something is missing, open an issue.
Functions
The core idea is simple: a Zig function is a Lua-callable function. You declare typed parameters, zua decodes the Lua arguments into them, and you return a Result.
Your first function
fn add(a: i32, b: i32) Result(i32) {
return Result(i32).ok(a + b);
}
To register it, decide whether the function needs access to the Zua instance. If not, use ZuaFn.pure:
globals.setFn("add", ZuaFn.pure(add, .{
.parse_error = "add expects (i32, i32)",
}));
If it does, use ZuaFn.from and add *Zua as the first parameter:
fn greet(z: *Zua, name: []const u8) Result([]const u8) {
const msg = std.fmt.allocPrint(z.allocator, "hello, {s}", .{name})
catch return Result([]const u8).errStatic("out of memory");
return Result([]const u8).owned(msg);
}
globals.setFn("greet", ZuaFn.from(greet, .{
.parse_error = "greet expects (string)",
}));
The parse_error message is what Lua sees if the arguments do not match. Make it useful.
Return values
Result(T) wraps your return type. For a single value, ok is all you need:
fn answer(_: *Zua) Result(i32) {
return Result(i32).ok(42);
}
For multiple return values, use an anonymous tuple:
fn minmax(_: *Zua, a: f64, b: f64) Result(.{ f64, f64 }) {
return Result(.{ f64, f64 }).ok(.{ @min(a, b), @max(a, b) });
}
For no return value:
fn log(_: *Zua, msg: []const u8) Result(.{}) {
std.log.info("{s}", .{msg});
return Result(.{}).ok(.{});
}
Allocated strings
When you allocate a string to return, use owned instead of ok. The trampoline takes ownership and frees it after pushing to Lua:
fn format(z: *Zua, value: i32) Result([]const u8) {
const text = std.fmt.allocPrint(z.allocator, "value={d}", .{value})
catch return Result([]const u8).errStatic("out of memory");
return Result([]const u8).owned(text);
}
Always allocate with z.allocator. The trampoline frees with the same allocator, so mixing allocators will go wrong.
Errors
Three constructors cover the common cases:
return Result(i32).errStatic("missing pid");
return Result(i32).errOwned(z.allocator, "pid {d} out of range", .{pid});
return Result(i32).errZig(err);
errStatic takes a string literal. errOwned formats a message on the allocator and frees it before raising the Lua error. errZig surfaces a Zig error value by name.
Errors always raise a Lua error after your function returns. Zig defer blocks run normally, which is the main reason zua uses this pattern instead of calling lua_error directly from inside the callback.
Callbacks can also return !Result(T). Zig errors propagate through the trampoline and become Lua errors automatically:
fn readFile(z: *Zua, path: []const u8) !Result([]const u8) {
const contents = try std.fs.cwd().readFileAlloc(z.allocator, path, 1024 * 1024);
return Result([]const u8).owned(contents);
}
Configuring error messages
The second argument to ZuaFn.pure and ZuaFn.from is a ZuaFnErrorConfig. You can pass an anonymous struct literal for convenience, but the full type gives you three fields:
globals.setFn("read_file", ZuaFn.from(readFile, ZuaFnErrorConfig{
.parse_error = "read_file expects (string)",
.zig_err_fmt = "read_file failed: {s}",
.zig_err_hook = null,
}));
parse_error is what Lua sees when argument decoding fails. zig_err_fmt is a format string that receives the Zig error name as {s}. If you need to produce a more descriptive message at runtime, use zig_err_hook instead:
fn describeError(z: *Zua, err: anyerror) []const u8 {
return std.fmt.allocPrint(z.allocator, "file error ({s}): check path and permissions", .{
@errorName(err),
}) catch "file error";
}
globals.setFn("read_file", ZuaFn.from(readFile, ZuaFnErrorConfig{
.parse_error = "read_file expects (string)",
.zig_err_hook = describeError,
}));
The hook takes precedence over zig_err_fmt when both are set. The returned string must be allocated with z.allocator. zua frees it after raising the Lua error.
Optional parameters
Declare optional parameters as ?T. A missing trailing argument or an explicit nil both decode as null:
fn add(a: i32, b: ?i32) Result(i32) {
return Result(i32).ok(a + (b orelse 0));
}
Lua can call this as add(1) or add(1, nil) and both work.
Supported parameter types
i32, i64, f32, f64, bool, []const u8, [:0]const u8, structs, and ?T for any of the above. Tables and userdata are covered in later chapters.
Data
Most real APIs pass more than a few scalars. This chapter covers how to move structured data across the boundary in both directions.
Structs as function arguments
When a Lua caller passes a config table, declare the corresponding Zig struct as a parameter and zua decodes the fields automatically:
fn printConfig(_: *Zua, config: struct {
name: []const u8,
version: i32,
}) Result(.{}) {
std.debug.print("{s} v{d}\n", .{ config.name, config.version });
return Result(.{}).ok(.{});
}
printConfig({ name = "myapp", version = 1 })
Any named struct works too, the inline form is just convenient when the type is only used once.
Optional fields decode as null when the Lua table does not have that key or has nil for it. Nested structs decode recursively from nested Lua tables:
const Range = struct { min: f64, max: f64 };
const ScanOptions = struct {
type_name: []const u8,
eq: ?f64,
in_range: ?Range,
};
This decodes both:
{ type_name = "f32", eq = 8.3 }
{ type_name = "u32", in_range = { min = 0, max = 255 } }
Sum types
Lua tables that represent a sum type, where only one of several optional fields is present, do not map directly to Zig tagged unions. Decode into a flat struct first, then convert:
const Condition = union(enum) { eq: f64, in_range: Range };
fn decodeCondition(eq: ?f64, in_range: ?Range) !Condition {
if (eq != null and in_range != null) return error.InvalidType;
if (eq) |v| return .{ .eq = v };
if (in_range) |r| return .{ .in_range = r };
return error.InvalidType;
}
The two-step approach is intentional. Struct decode handles the mechanical field extraction. Your conversion function encodes the validation rules and error messages that belong to your domain. You can see this pattern in practice in the memscript API example, where scan options arrive as a flat table and get converted to a Condition union before any logic runs.
Building tables to return
Use tableFrom when you already have data you want to push to Lua:
const guide = z.tableFrom(.{
.name = "guided-tour",
.version = 1,
.tags = [_][]const u8{ "zig", "lua" },
});
defer guide.pop();
globals.set("guide", guide);
Struct fields become string keys. Arrays and slices become array-style Lua tables. Nesting is recursive.
When you need to build a table incrementally, use createTable and set:
const entry = z.createTable(0, 3);
defer entry.pop();
entry.set("address", "0x7fff1234");
entry.set("type", "f32");
entry.set("value", 8.3);
globals.set("entry", entry);
Returning a table from a callback
Return Result(Table) and push the table as the return value:
fn makeEntry(z: *Zua, address: []const u8) Result(Table) {
const t = z.createTable(0, 2);
t.set("address", address);
t.set("type", "f32");
return Result(Table).ok(t);
}
The trampoline pushes the table value and pops the handle after your function returns.
Adding methods to tables
Methods are Zig functions registered on a table. Lua calls them with : syntax, which passes the receiver table as the first argument:
fn increment(_: *Zua, self: Table, delta: i32) Result(i32) {
const current = self.get("count", i32)
catch return Result(i32).errStatic("count missing");
const next = current + delta;
self.set("count", next);
return Result(i32).ok(next);
}
const counter = z.tableFrom(.{ .count = 0 });
defer counter.pop();
counter.setFn("increment", ZuaFn.from(increment, .{
.parse_error = "counter:increment expects (i32)",
}));
globals.set("counter", counter);
counter:increment(5)
Decoding a table you already have
When you have a Table handle rather than a direct function argument, use translation.decodeStruct:
const guide_table = try globals.get("guide", zua.Table);
defer guide_table.pop();
const guide = try zua.translation.decodeStruct(zua.Table, guide_table, struct {
name: []const u8,
version: i32,
});
This is also useful inside callbacks that receive a table as self and need to read several fields in one go rather than calling get repeatedly.
Types
Tables are good for DTOs, plain data you want Lua to read and write. But sometimes you need a Zig value that has identity, methods, and a lifetime that Lua does not control. That is what translation strategies and the ZUA_META declaration are for.
Translation strategies
Declare ZUA_META on a struct to tell zua how to represent it in Lua. There are three options.
.object - userdata with methods
The struct is allocated as Lua userdata with a metatable. Methods receive self: *T, so they can mutate the value. This is the right choice when the value needs to stay in Zig-owned memory and Lua should interact with it through a defined interface.
const Entry = struct {
pub const ZUA_META = zua.meta.Object(Entry, .{
.get = get,
.set = set,
.__tostring = toString,
});
address: u64,
value: f64,
pub fn get(self: *Entry) Result(f64) {
return Result(f64).ok(self.value);
}
pub fn set(self: *Entry, v: f64) Result(.{}) {
self.value = v;
return Result(.{}).ok(.{});
}
pub fn toString(z: *Zua, self: *Entry) Result([]const u8) {
const msg = std.fmt.allocPrint(z.allocator, "Entry(0x{X}, {d})", .{
self.address, self.value,
}) catch return Result([]const u8).errStatic("out of memory");
return Result([]const u8).owned(msg);
}
};
local e = make_entry(0xdeadbeef)
e:set(8.3)
print(e:get()) -- 8.3
print(tostring(e)) -- Entry(0xDEADBEEF, 8.3)
.table - Lua table with methods
Fields become table keys. Lua code can read and write them directly. Methods receive self: T for read-only access or self: Table when they need to mutate fields on the Lua side.
const Point = struct {
pub const ZUA_META = zua.meta.Table(Point, .{
.distance = distance,
});
x: f64,
y: f64,
pub fn distance(self: Point) Result(f64) {
return Result(f64).ok(std.math.sqrt(self.x * self.x + self.y * self.y));
}
};
local p = make_point()
p.x = 3
p.y = 4
print(p:distance()) -- 5
When no strategy is declared, .table is the default.
Tagged unions work naturally with .table. Lua passes a single-key table to select the active variant, zua decodes whichever field is present:
const Range = struct { min: f64, max: f64 };
const Condition = union(enum) {
eq: f64,
in_range: Range,
pub const ZUA_META = zua.meta.Table(Condition, .{});
};
process:scan({ eq = 8.3 })
process:scan({ in_range = { min = 0, max = 255 } })
Only one field may be set; zero or more than one fails with a type error. Tagged unions can also use .object or .zig_ptr when you want the variant to stay opaque to Lua.
.zig_ptr - opaque pointer
Light userdata. No methods, no metatable. Lua can hold the value and pass it back to Zig functions, but cannot inspect or modify it. This is the right choice for handles that should be completely opaque to Lua.
const Context = struct {
pub const ZUA_META = zua.meta.Ptr(Context);
multiplier: f64,
};
fn scale(ctx: *Context, value: f64) Result(f64) {
return Result(f64).ok(value * ctx.multiplier);
}
local ctx = get_context()
print(scale(ctx, 10))
Methods and metamethods
Methods are declared in the ZUA_META via meta.Object(), meta.Table() etc. as a comptime tuple of name-function pairs. Names starting with __ are metamethods and go directly on the metatable. Everything else goes in __index.
const T = struct {
pub const ZUA_META = zua.meta.Object(T, .{
.normalize = normalize,
.__tostring = toString,
.__add = add,
});
// ...
};
The first parameter of a method determines how self is received:
.object:self: *Tfor mutable access, or*Zuathen*T.table:self: Tfor read-only,self: Tablefor mutable, or*Zuafirst in either case
Metamethods follow the same rules. __tostring and binary operators like __add and __mul work as you would expect from the Lua manual.
Customizing method error handling
Methods can be wrapped in ZuaFn to customize error handling. Instead of a bare function, use ZuaFn.from() or ZuaFn.pure() with a ZuaFnErrorConfig to control how Zig errors are reported to Lua:
const Counter = struct {
pub const ZUA_META = zua.meta.Object(Counter, .{
.increment = zua.ZuaFn.pure(increment, .{
.zig_err_fmt = "increment failed: {s}",
}),
});
count: i32 = 0,
pub fn increment(self: *Counter, amount: i32) Result(.{}) {
self.count += amount;
return Result(.{}).ok(.{});
}
};
Custom hooks
Sometimes you need control over how a type encodes to Lua or decodes from Lua. Use .withEncode() and .withDecode() builder methods on the ZUA_META declaration.
Encode hooks
An encode hook transforms a value into a different type before it is pushed to Lua. The classic use is encoding an enum as a string instead of an integer:
const Status = enum(u8) {
idle = 0,
running = 1,
stopped = 2,
pub const ZUA_META = zua.meta.Table(Status, .{})
.withEncode([]const u8, encodeAsString);
fn encodeAsString(status: Status) []const u8 {
return switch (status) {
.idle => "idle",
.running => "running",
.stopped => "stopped",
};
}
};
The hook must return a different type than its input. This is enforced to prevent infinite recursion.
For enums specifically, strEnum() derives both hooks automatically from field names so you do not need to write the switch:
const Direction = enum { north, east, south, west };
// directly on the enum
pub const ZUA_META = zua.meta.Table(Direction, .{}).strEnum();
Decode hooks
A decode hook lets a type accept multiple Lua value types and convert them. Useful when you want a flexible API that accepts an address as an integer or an existing handle:
const Address = struct {
pub const ZUA_META = zua.meta.Table(Address, .{}).withDecode(decodeHook);
value: u64,
fn decodeHook(z: *zua.Zua, index: zua.lua.StackIndex, kind: zua.lua.Type) !Address {
return switch (kind) {
.number => blk: {
const n = zua.lua.toInteger(z.state, index) orelse return error.InvalidType;
break :blk Address{ .value = @intCast(n) };
},
.userdata => blk: {
const ptr = zua.lua.toUserdata(z.state, index) orelse return error.InvalidType;
const addr_ptr: *Address = @ptrCast(@alignCast(ptr));
break :blk Address{ .value = addr_ptr.value };
},
else => error.InvalidType,
};
}
};
Now any function that takes an Address parameter accepts both integers and userdata handles from Lua without any changes to the function itself.
Asymmetry is fine
Encode and decode hooks are independent. You can have a type that encodes as a string but still decodes from an integer. The asymmetry is intentional, not a limitation.
Host State
Callbacks often need access to application state that Lua should not touch directly. The standard pattern is a light userdata pointer stored in the Lua registry.
Storing state
var app = AppState{ .next_id = 1000 };
const registry = z.registry();
defer registry.pop();
registry.setLightUserdata("app", &app);
Reading state inside a callback
fn nextId(z: *Zua) Result(i32) {
const registry = z.registry();
defer registry.pop();
const app = registry.getLightUserdata("app", AppState)
catch return Result(i32).errStatic("app state missing");
app.next_id += 1;
return Result(i32).ok(app.next_id - 1);
}
Hidden pointer on a table
You can attach a private pointer directly to a Lua-facing table. This is useful when a table wraps a specific Zig value rather than shared global state:
const entry_table = z.createTable(0, 3);
entry_table.set("address", "0x7fff1234");
entry_table.setLightUserdata("_ptr", entry_ptr);
entry_table.setFn("get", ZuaFn.from(entryGet, .{
.parse_error = "entry:get takes no arguments",
}));
Inside the method:
fn entryGet(z: *Zua, self: Table) Result(f64) {
const entry = self.getLightUserdata("_ptr", Entry)
catch return Result(f64).errStatic("entry pointer missing");
_ = z;
return Result(f64).ok(entry.read());
}
The _ptr naming convention signals to Lua authors that the field is private. Lua can still read it, but it is not part of the public API.
Lifetime
Light userdata is a raw pointer. The pointed-to value must outlive the Zua instance. zua does not track or manage that lifetime.
Running Lua
Executing for side effects
try z.exec("print('hello world')");
Errors come back as Zig errors:
z.exec("bad lua here") catch |err| {
std.debug.print("error: {}\n", .{err});
};
Evaluating with typed return values
eval decodes Lua return values directly into a typed Zig tuple:
const result = try z.eval(i32, "return 1 + 2");
const data = try z.eval(.{ []const u8, i32 }, "return 'bob', 42");
std.debug.print("{s} is {d}\n", .{ data[0], data[1] });
Files
try z.execFile("init.lua");
const config = try z.evalFile(.{ []const u8, i32 }, "config.lua");
Error tracebacks
When you need the full Lua stack trace, use execTraceback:
const result = try z.execTraceback("bad code here");
defer z.freeTraceBackResult(result);
switch (result) {
.Ok => {},
.Runtime => |msg| std.debug.print("runtime error:\n{s}\n", .{msg}),
.Syntax => |msg| std.debug.print("syntax error:\n{s}\n", .{msg}),
else => |msg| std.debug.print("error:\n{s}\n", .{msg}),
}
Always call freeTraceBackResult to free the allocated message.
Building a REPL
checkChunk tells you whether a piece of Lua source is syntactically complete or waiting for more input:
var buffer = std.ArrayList(u8).init(allocator);
defer buffer.deinit();
while (true) {
// read a line from stdin into `line`...
try buffer.appendSlice(line);
try buffer.appendSlice("\n");
if (!try z.checkChunk(buffer.items)) {
// incomplete, keep reading
continue;
}
try z.exec(buffer.items);
buffer.clearRetainingCapacity();
}
For expression-style results (where = 1 + 2 should print 3), use canLoadAsExpression to detect whether the input is a valid expression before executing. loadChunk and callLoadedChunk let you load code once and execute it multiple times, or inspect return values from the stack directly.