Best way to handle optional values through C ABI

I am currently writing code to make a sort of Zig ↔ JS bridge thanks to WASM.
But I face this issue : some JS function have facultative arguments, with a default value.

I wrap them with a C ABI compatible JS function, but what is the most efficient way to indicate whether one argument should be used or not ?

My first guess, for boolean, was to use an u8 with false0, true1, null2. But this is a waste of resources and does not apply to other types.

My second guess was to use a mask to indicate which values need to be used or not. For example, if the function have four optional arguments, I can add an extra argument mask = 0b00001011 to tell the JS wrapper to not use the 3rd argument.

Is there a more efficient or proper way to do it ?

EDIT: it wasn’t clear, but I can’t know what is the default value (because it depends on browser and other parameters) and all the JS function with optional parameters are black box wrapped in my custom functions that I link with Zig.

I would try to reify the problem by introducing an intermediate JS function with all parameters mandatory, and to define your function with optional arguments in terms of that one.
Something like this:

function f(a, b=3) {
    g(a, b);
}

function g(a, b) {
    console.log(a,b);
}

And then crossing the ABI with g instead of f. That would avoid the need for any tricks.

Do you need varargs? Also do you need both way communication?
I am not good with js/wasm so do you have more examples how it looks on code level?
Does any part of your functions written in js?

The problem with this solution is that I need to modify f, wich I can’t in some case.
For example, if i want to use the function document.body.addEventListener(). EventTarget: addEventListener() method - Web APIs | MDN
The two first arguments are mandatory, but the 3rd is optional. And depending on the value of the first argument and the browser used, the default value can differ !

Yes, varargs sounds like a solution to this problem. Is it possible with the C ABI ???

I am preparing a good example with real code.

It is probably not what I reccomend but yeah you can do C variadic Documentation - The Zig Programming Language

It seems cool but will be a mess to figure out which argument I pass or not, in the case of multiple optional arguments.

Yeah but still I firstly meant: “do you need to communicate with javascript functions which have varargs?”

To keep it simple, I omitted all the code related to wasm loading, string parsing, object sharing, etc.

In my JS code:

function js_wrapper_addEventListener(target, event_name, event_id, callback_id, capture, once, passive, signal, use_capture) {
    getTarget(target).addEventListener(/* add all the options and a custom function that call the right zig function on event */)
}

In the Zig side:

extern fn js_addEventListener(target: u32, event_name: [*]const u8, event_id: u8, callback_id: u32, capture: bool, once: bool, passive: bool, signal: u32, use_capture: bool) void;

const AddEventListenerOptions = struct {
    capture: ?bool = null,
    once: ?bool = null,
    passive: ?bool = null,
    signal: ?AbortSignal = null,
};

pub fn addEventListener(target: EventTarget, event: Event, callback: *const fn (EventTarget, Event) void, options: AddEventListenerOptions, useCapture: ?bool) void {
    callbacks[callback_count] = callback;
    js_addEventListener(
        self.js_id,
        @tagName(event).ptr,
        @intFromEnum(event),
        callback_count,
        // add all the other arguments here
    );
    callback_count += 1;
    return;
}

I misunderstood. No, all the js function I directly interact with are my custom wrappers with a fixed number of argument. It is the function I wrap that have optional argument and unknown default values.

Here’s what I came up with:

pub fn DefaultValue(T: type, DEFAULT: T) type {
	return extern struct{
		inner: T,
		
		pub const default: @This() = .{
			.inner = DEFAULT,
		};
		
		pub fn value(v: T) @This() {
			return .{
				.inner = v,
			};
		}
	};
}

I couldn’t be fucked to set-up a WASM project just to test this, so here’s a testing Windows program:

const std = @import("std");

pub fn main() !void {
	switch(SetConsoleCtrlHandler(
		&win32_ctrl_handler,
		.value(true),
	)){
		.TRUE => {}, // No error
		.FALSE => std.log.err("{t}: Win32 interrupt handler failure!", .{std.os.windows.GetLastError()}),
		else => unreachable,
	}
	while(true){}
}

/// Win32 keyboard interrupt handler
fn win32_ctrl_handler(ctrl: u32) callconv(.c) std.os.windows.BOOL {
	switch(ctrl){
		0 => std.log.info("Goodbye!", .{}), // CTRL-C
		else => unreachable,
	}
	// Allow default handler to be run after us, and safely exit the process
	return .FALSE;
}

extern "kernel32" fn SetConsoleCtrlHandler(
	HandlerRoutine: ?*const anyopaque,
	Add: BOOL, // TRUE to add, FALSE to remove
) std.os.windows.BOOL;

pub fn DefaultValue(T: type, DEFAULT: T) type {
	return extern struct{
		inner: T,
		
		pub const default: @This() = .{
			.inner = DEFAULT,
		};
		
		pub fn value(v: T) @This() {
			return .{
				.inner = v,
			};
		}
	};
}

pub const BOOL = DefaultValue(bool, false);

(Of course, since I didn’t bother to actually test this method with WASM, I can’t be certain that it’d work there.)

As you can see, the strategy of using an extern struct allows us to use decl literals (.value(true) or .default) in the function’s arguments, which is a relatively clean way of doing this.

1 Like

I forgot to mention that I don’t know what is the default value.

I see. If it were me, I wouldn’t try to create a fully ‘polymorphic’ interface to these JS functions ; I would instead figure out my use cases for the functions and create distinct wrappers for each of these cases on the JS side and then interface with these wrappers. It’s much more rigid and less generic, but it’s also much less of a headache :^).

If you do need (for some reason) the fully capable interface to such JS functions, I doubt you’ll avoid some large machinery around it.

1 Like

I personally like the solution, its clean but IIUC the default value comes not from zig side but from js side that is the main problem and the OP needs to tell the js side “it is stub, use your default” via C ABI

Look, I don’t really know what is a best way but if you are going to stick with a mask idea here is what you can do:

const std = @import("std");

pub fn ParameterMask(comptime params: anytype) u16 {
    var bitset: std.bit_set.Integer(16) = .empty;
    const info = switch (@typeInfo(@TypeOf(params))) {
        .@"struct" => |s| s,
        else => @compileError("Expected struct"),
    };

    inline for (info.field_names, info.field_types, 0..) |field_name, field_type, i| {
        const type_info = @typeInfo(field_type);
        // Outer check is redundant if you are not going to pass tuples
        if (type_info != .null) {
            if (type_info != .optional or @field(params, field_name) != null)
                bitset.set(i);
        }
    }
    return bitset.mask;
}

test ParameterMask {
    const Options = struct {
        p1: ?u8 = null,
        p2: ?u8 = null,
        p3: u8 = 0,
        p4: u8 = 0,
    };
    try std.testing.expectEqual(0b1101, ParameterMask(Options{ .p1 = 0 }));
    try std.testing.expectEqual(0b0110, ParameterMask(.{null, 0, 2343, null}));
}

But its not everything. If you are ready to put all the params of c func into a struct you could also with some help of comptime magic to create sugared function similar to @call(c_func, args) which will actually work with both tuples and structs (in comparison to @call) and will automatically add mask to the call but it is a bit more tedious and will restrict you to only extern compatible types in the Options structs

It is a reworked reap-of from TrailerFlags. It was interesting thing to read (I even already made wait till codeberg will stop giving error 500 and allow me to create PR which improves ergonomics of TrailerFlags)

@alberic89 hi, just interesting what approach did you settle with? Did you found any better solution?

I would just bite the bullet in this case and just have two different wrapper functions which the Zig side (one for the two argument version with the default and for the three argument without the default one) calls into and are implemented on the JS side to call the function for you.

So essentially:

Zig:

fooWrap2(1, 2);
fooWrap3(1, 2, 3);

JS:

function fooWrap2(a, b) {
    return foo(a, b);
}
function fooWrap3(a, b, c) {
    return foo(a, b, c);
}

Afaik C variadic functions work differently in their argument passing than functions with default values (well, it technically depends on the platform ABI). So that solution wouldn’t really work.

Having different default values depending on the parameter values sounds like really bad API design. But from what I can tell it’s not something you can change.

1 Like

This seems to be the best solution for most case.

I can’t argue with you on that. The web API is a real mess. Some behaviors are browser-dependent. And did you know that some WebAssembly function’s behavior depends on the endianess of the host, whereas some have a parameter to choose the endianess ? :exploding_head:

I was going on with a basic mask solution, but I think I will mix it with the solution proposed by @KilianHanich. Using mask for object with a lot of optional values, and multiple wrappers for functions with some optional arguments.