Userdata (Extern Types)
Work in progress
This page is not up to our standards yet.
This can be because is not complete or hasn't been reviewed and copyedited properly. Some details may be poorly worded, wrong, or unclear. Please let me (deviaze) know on Discord if you need clarification on anything.
Userdata is the primitive type for values that belong to the host program instead of the script, like a game engine's sprites or a runtime's file handles and HTTP responses. The host allocates and frees a userdata's memory, and the metatable it attaches decides what scripts can do with the value.
An extern type is the type checker's description of one kind of userdata. Because the type checker can't read host code, the host declares an extern type for each kind of userdata it hands out. The declaration lists the fields and methods scripts can use, and the analyzer checks every use against it.
What userdata is
type(value)is"userdata"andtypeof(value)is the name in its metatable's__typefield.- Indexing, method calls and operators on userdata all go through its metatable (
__index,__add,__tostringand the rest). - Only the host can create userdata or give it a metatable.
setmetatableonly works on tables. - Full userdata owns a block of memory that the garbage collector frees once scripts stop referencing it. Light userdata is a bare pointer that owns no memory and has no metatable per value.
- Classes are defined in Luwu code. Userdata is for objects the host defines in C, C++ or Rust.
Creating userdata in C
A host written in C or C++ uses Luwu's C API. Each kind of userdata gets a tag (a small integer the host picks) and one metatable that every value with that tag shares. This Counter holds a number that scripts can increase and read:
#include "lua.h"
#include "lualib.h"
enum { COUNTER_TAG = 1 };
typedef struct Counter {
double count;
} Counter;
static Counter* check_counter(lua_State* L, int index) {
Counter* counter = (Counter*)lua_touserdatatagged(L, index, COUNTER_TAG);
if (!counter)
luaL_typeerror(L, index, "Counter");
return counter;
}
static int counter_increment(lua_State* L) {
Counter* counter = check_counter(L, 1);
counter->count += luaL_optnumber(L, 2, 1);
return 0;
}
static int counter_value(lua_State* L) {
lua_pushnumber(L, check_counter(L, 1)->count);
return 1;
}
static int new_counter(lua_State* L) {
double start = luaL_optnumber(L, 1, 0);
Counter* counter =
(Counter*)lua_newuserdatataggedwithmetatable(L, sizeof(Counter), COUNTER_TAG);
counter->count = start;
return 1;
}
void open_counter(lua_State* L) {
lua_createtable(L, 0, 2); // the metatable every Counter shares
lua_pushstring(L, "Counter");
lua_setfield(L, -2, "__type"); // what typeof() returns
lua_createtable(L, 0, 2); // methods, looked up through __index
lua_pushcfunction(L, counter_increment, "increment");
lua_setfield(L, -2, "increment");
lua_pushcfunction(L, counter_value, "value");
lua_setfield(L, -2, "value");
lua_setfield(L, -2, "__index");
lua_setreadonly(L, -1, 1);
lua_setuserdatametatable(L, COUNTER_TAG); // pops the metatable
lua_pushcfunction(L, new_counter, "new_counter");
lua_setglobal(L, "new_counter");
}lua_setuserdatametatableregisters the metatable for the tag andlua_newuserdatataggedwithmetatablecreates a value that already has it.- Each method gets its
Counterfromlua_touserdatatagged. It returnsNULLfor any other value andluaL_typeerrorraises an error naming the type that was expected. - Call
open_counter(L)after creating the state. Luwu's VM is built as C++ and its headers don't declareextern "C". Compile this file as C++ or defineLUA_APIasextern "C"when building Luwu. - Luwu doesn't run
__gcmetamethods. When userdata holds a resource such as a file descriptor, register a destructor withlua_setuserdatadtor(L, COUNTER_TAG, close_counter)and the garbage collector calls it before freeing the memory.
Creating userdata in Rust
A Rust host can use mluau, the fork of mlua that seal uses. Implementing UserData for a struct turns its values into userdata, and add_methods fills in the metatable:
use std::cell::Cell;
use mluau::{Lua, Result, UserData, UserDataMethods};
struct Counter {
count: Cell<f64>,
}
impl UserData for Counter {
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("increment", |_, counter, by: Option<f64>| {
counter.count.set(counter.count.get() + by.unwrap_or(1.0));
Ok(())
});
methods.add_method("value", |_, counter, ()| Ok(counter.count.get()));
}
}
fn open_counter(lua: &Lua) -> Result<()> {
let new_counter = lua.create_function(|_, start: Option<f64>| {
Ok(Counter { count: Cell::new(start.unwrap_or(0.0)) })
})?;
lua.globals().set("new_counter", new_counter)
}- Methods receive a shared reference to the
Counter. TheCellletsincrementchange the count through it.UserDataMutgives methods a mutable reference instead at the cost of aRefCellborrow on every access. typeofreturns the struct's name. OverrideUserData::type_nameto change it.
Using it from Luwu
Both hosts give scripts the same new_counter function, and this script prints the same output under either of them:
const clicks = new_counter()
clicks:increment()
clicks:increment(4)
print(type(clicks), typeof(clicks), clicks:value())userdata Counter 5Declaring the extern type
Until something declares new_counter, the analyzer reports it as an unknown variable. The host describes Counter with declare extern type and gives new_counter its signature with declare function in a definitions file:
declare extern type Counter with
function increment(self, by: number?): ()
function value(self): number
end
declare function new_counter(start: number?): Counter
declare function load_counter(name: string): Counter | stringPass the file to luwu-analyze and luwu-lsp with --defs. seal ships its declarations this way in .seal/typedefs/globals.d.luau. From then on the analyzer checks every use:
const clicks = new_counter()
clicks:increment("4")
clicks:reset()main.luwu(2,18): TypeError: Expected this to be 'number?', but was given 'string'
main.luwu(3,1): TypeError: Key 'reset' not found in external type 'Counter'Fields
A field is written like a table type's property. Scripts read sprite.name and the host answers through __index:
declare extern type Sprite with
name: string
function move(self, x: number, y: number): ()
endInheritance
An extern type can extend another with extends. It gets every field and method of its parent and can be passed anywhere the parent is expected:
declare extern type AnimatedSprite extends Sprite with
frame: number
function play(self, animation: string): ()
end
declare function find_sprite(name: string): AnimatedSpriteconst function slide(sprite: Sprite)
sprite:move(10, 0)
end
const dragon = find_sprite("dragon")
dragon:play("fly")
slide(dragon)
print(dragon.name, dragon.frame)Narrowing with typeof
Checking typeof against an extern type's name narrows a union to that type:
const saved = load_counter("clicks")
if typeof(saved) == "Counter" then
print(saved:value())
else
print(`couldn't load: {saved}`)
endDeclaring in a script
In Luwu, a script can also declare extern type for itself, and with is optional. The type is private to the file like a type alias, and export declare extern type lets other modules use it as module.Counter:
export declare extern type Counter
function increment(self, by: number?)
function value(self): number
endThings to know
- A declaration is a promise the host makes. Nothing checks at runtime that a userdata has the fields and methods its extern type lists.
- Declarations compile to nothing and only exist for the type checker.
- Extern types can take generic parameters, like a
Result<T>for every call that returns one. Generic Extern Types covers them.