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

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");
}

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

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	5

Declaring 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 | string

Pass 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): ()
end

Inheritance

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): AnimatedSprite
const 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}`)
end

Declaring 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
end

Things to know