luahaxe

type safe create Lua tables in Haxe without runtime overhead and without boilerplate


I am trying to write some externs to some Lua libraries that require to pass dictionary tables and I want to make them type safe. So far, I have been declaring abstract classes with public inline constructors, but this gets tedious really fast:

abstract JobOpts(Table<String, Dynamic>) {
    public inline function new(command:String, args:Array<String>) {
        this = Table.create(null, {
            command: command,
            arguments: Table.create(args)
        });
    }
}

Is there a better way that allows me to keep things properly typed but that does not require that much boilerplate?

Please note that typedefs and anonymous structures are not valid options, because they introduce nasty fields in the created table and also do a function execution to assign a metatable to them:

--typedef X = {cmd: String}
_hx_o({__fields__={cmd=true},cmd="Yo"})

My abstract code example compiles to a clean lua table, but it is a lot of boilerplate


Solution

  • Some targets support @:nativeGen to strip Haxe-specific metadata from objects, but this does not seem to be the case for typedefs on Lua target. Fortunately, Haxe has a robust macro system so you can make the code write itself. Say,

    Test.hx:

    import lua.Table;
    
    class Test {
        public static function main() {
            var q = new JobOpts("cmd", ["a", "b"]);
            Sys.println(q);
        }
    }
    @:build(TableBuilder.build())
    abstract JobOpts(Table<String, Dynamic>) {
        extern public inline function new(command:String, args:Array<String>) this = throw "no macro!";
    }
    

    TableBuilder.hx:

    import haxe.macro.Context;
    import haxe.macro.Expr;
    
    class TableBuilder {
        public static macro function build():Array<Field> {
            var fields = Context.getBuildFields();
            for (field in fields) {
                if (field.name != "_new") continue; // look for new()
                var f = switch (field.kind) { // ... that's a function
                    case FFun(_f): _f;
                    default: continue;
                }
                // abstract "constructors" transform `this = val;`
                // into `{ var this; this = val; return this; }`
                var val = switch (f.expr.expr) {
                    case EBlock([_decl, macro this = $x, _ret]): x;
                    default: continue;
                }
                //
                var objFields:Array<ObjectField> = [];
                for (arg in f.args) {
                    var expr = macro $i{arg.name};
                    if (arg.type.match(TPath({ name: "Array", pack: [] } ))) {
                        // if the argument's an array, make an unwrapper for it
                        expr = macro lua.Table.create($expr, null);
                    }
                    objFields.push({ field: arg.name, expr: expr });
                }
                var objExpr:Expr = { expr: EObjectDecl(objFields), pos: Context.currentPos() };
                val.expr = (macro lua.Table.create(null, $objExpr)).expr;
            }
            return fields;
        }
    }
    

    And thus...

    Test.main = function() 
      local this1 = ({command = "cmd", args = ({"a","b"})});
      local q = this1;
      _G.print(Std.string(q));
    end
    

    Do note, however, that Table.create is a bit of a risky function - you will only be able to pass in array literals, not variables containing arrays. This can be remedied by making a separate "constructor" function with the same logic but without array➜Table.create unwrapping.