Hi all! I'm looking to match on a Try(..) in the host code for my Zig-based platform. Zig glue generates the following type for me:
pub const ProcessResult = if (@sizeOf(usize) == 4) extern struct {
payload: [12]u8 align(4),
tag: ProcessResultTag,
pub fn payload_err(self: *const @This()) RocStr { .. }
pub fn payload_ok(self: *const @This()) RocListWith(u8, false) { .. }
pub fn decref(self: @This(), roc_host: *RocHost) void { .. }
pub fn incref(self: @This(), amount: isize) void { .. }
} else extern struct {
payload: ProcessResultPayload,
tag: ProcessResultTag,
pub fn payload_err(self: *const @This()) RocStr { .. }
pub fn payload_ok(self: *const @This()) RocListWith(u8, false) { .. }
pub fn decref(self: @This(), roc_host: *RocHost) void { .. }
pub fn incref(self: @This(), amount: isize) void { .. }
};
The payload_ok and payload_err methods are useful if you know the the value of the tag, which I don't in this case. I'm a bit hesitant to manually test the tag field, because there's two versions of the struct depending on @sizeOf(usize) and I don't want to accidentally introduce a compilation error on some targets.
I guess ideally for Zig here there would be a helper method that returns a Zig tagged union (which I believe should be possible without allocating), but maybe there's downsides to that. What do others do in this instance?
the challenge of returning a zig tagged union is I don't think they support extern tagged unions because their layout isn't stable :thinking:
unless I'm misremembering (I'm on mobile haha)
I don't have a need to consume effect results, as the zig host is the one producing them.
And the app is the one consuming them, and deciding how to handle them.
Although, let's say I did have a need for the application author to feed a Try result back to zig, for zig to handle.
The glue generated this for the tag:
/// Tag discriminant for Try.
pub const HostRegister_classResultTag = enum(u8) {
@"Err" = 0,
@"Ok" = 1,
};
/// Payload union for Try.
pub const HostRegister_classResultPayload = extern union {
@"err": NameLengthErrOrOutOfMemoryClassErr,
@"ok": u32,
};
In your example: pub const ProcessResult = if (@sizeOf(usize) == 4) extern struct {
, in both cases, the tag is the same tag: ProcessResultTag,... It's only the payload that's different.
Matching example:
const tag : abi.HostRegister_classResultTag = .abi.HostRegister_classResultTag.Ok;
const r = switch (tag) {
.abi.HostRegister_classResultTag.Ok => {
"It's Ok tag";
},
.abi.HostRegister_classResultTag.Err => {
"It's Err tag";
},
};
return r;
And in my case, I have two error types, so there is top level Err tag, with a payload with another ErrTypeDiscriminator tag inside it:
/// Tag discriminant for NameLengthErrOrOutOfMemoryClassErr.
pub const NameLengthErrOrOutOfMemoryClassErrTag = enum(u8) {
@"NameLengthErr" = 0,
@"OutOfMemoryClassErr" = 1,
};
/// Payload union for NameLengthErrOrOutOfMemoryClassErr.
pub const NameLengthErrOrOutOfMemoryClassErrPayload = extern union {
@"name_length_err": RocStr,
@"out_of_memory_class_err": RocStr,
};
Two constructor function examples:
fn registerClassResultOk(class_id: u32) abi.HostRegister_classResult {
if (comptime @sizeOf(usize) == 4) {
var result: abi.HostRegister_classResult = undefined;
result.tag = .Ok;
const p: *u32 = @ptrCast(@alignCast(&result.payload));
p.* = class_id;
// clear any leftover bytes beyond the u32 if you care
return result;
} else {
return .{
.payload = .{ .ok = class_id },
.tag = .Ok,
};
}
}
fn registerClassResultErr(
err_tag: abi.NameLengthErrOrOutOfMemoryClassErrTag,
msg: abi.RocStr,
) abi.HostRegister_classResult {
if (comptime @sizeOf(usize) == 4) {
// Build inner error value into its opaque payload
var err_val: abi.NameLengthErrOrOutOfMemoryClassErr = undefined;
err_val.tag = err_tag;
const str_ptr: *abi.RocStr = @ptrCast(@alignCast(&err_val.payload));
str_ptr.* = msg;
var result: abi.HostRegister_classResult = undefined;
result.tag = .Err;
const err_bytes = std.mem.asBytes(&err_val);
@memset(&result.payload, 0);
@memcpy(result.payload[0..err_bytes.len], err_bytes);
return result;
} else {
const err_val: abi.NameLengthErrOrOutOfMemoryClassErr = switch (err_tag) {
.NameLengthErr => .{
.payload = .{ .name_length_err = msg },
.tag = .NameLengthErr,
},
.OutOfMemoryClassErr => .{
.payload = .{ .out_of_memory_class_err = msg },
.tag = .OutOfMemoryClassErr,
},
};
return .{
.payload = .{ .err = err_val },
.tag = .Err,
};
}
}
Note: I'm using these function to return zig errors, back to the app as the result for when the zig effect fails.
Payload constructors already exist, it's only if you want to construct an entire Result, there isn't a constructor function for that yet. And an LLM helped me to write my slop constructor functions... so perhaps I could just call the glue payload constructors, and discard the additional logic.
"But I don't want to 'if (comptime @sizeOf(usize) == 4) {' because the ZigGlue.roc could change, and break the payload (or any other part of the) ABI, and if the zig type checker doesn't catch it... because it's an opaque type or something..."
(One could use the glue generated payload constructors abi.HostRegister_classResult.payload_ok(1234), where the if (@sizeOf(usize) == 4) extern struct { logic has already been implemented for you in the generated glue, instead of LLM copy pasting the implementation, or..)
One could vendor or pin a version of ZigGlue.roc, and add additional constructor functions, to enforce that they're both kept the same "at glue generation time".
It's just a matter of preference, "do i want the thinnest possible glue with just ABI types, or a more fully featured glue with constructor helper functions for everything".
I do think that the more beginner friendly approach, could be to include entire Result constructor helper functions in the default upstream glue.
And if you're desperate enough, one could even swap out the default glue platform entirely for something even more custom, like in my case where I have a need to generate something more complex, then just exclusively roc abis and extern stubs, but also export implementations for secondary glue-to-glue functions because I have two C-FFIs to contend with.
This is how I'm currently using the default upstream glue in my CI pipeline...
# generate roc platfrom bindings for game engine...
_gdextension_interface_out = Cmd.exec!("roc", ["run", "scripts/gdextension_interface.generate.roc"])?
_gdextension_interface_test_out = Cmd.exec!("zig", ["test", "src/engine/gdextension_interface.generated.zig"])?
_extension_api_out = Cmd.exec!("roc", ["run", "scripts/extension_api.generate.roc"])?
_platform_check_out = Cmd.exec!("roc", ["check", "platform/main.roc"])?
_glue_out = Cmd.exec!("roc", ["glue", "vendor/roc/git-1d982dc/ZigGlue.roc", "src/", "platform/main.roc"])?
# Patch glue... to support wasm32-emscripten
generated_glue = Path.read_utf8!("src/roc_platform_abi.zig")?
s1 = find_and_replace_first(
generated_glue,
# --- find (unpatched, as emitted by roc glue) ---
\\ fn nativeWriteStderr(_: ?*anyopaque, data: []const u8) void {
\\ std.Io.File.stderr().writeStreamingAll(std.Io.Threaded.global_single_threaded.io(), data) catch {};
\\ }
,
# --- replace ---
\\// PATCHED BY scripts/glue.roc
\\fn nativeWriteStderr(_: ?*anyopaque, data: []const u8) void {
\\ _ = data;
\\}
,
)
s2 = find_and_replace_first(
s1,
\\ fn nativeOnFatal(_: ?*anyopaque) noreturn {
\\ std.process.exit(1);
\\ }
,
\\// PATCHED BY scripts/glue.roc
\\fn nativeOnFatal(_: ?*anyopaque) noreturn {
\\ @trap();
\\}
,
)
# Write patched glue...
Path.write_utf8!("src/roc_platform_abi.zig", s2)?
Where I'm at:
![]()
I need to also generate implementations... so my glue needs to know about both C-FFIs. The project is 95% "game engine <-> zig <-> roc" glue, and 5% roc host impl, handling raw pointers and wrangling dynamic OOP style to work with static FP style.
I don't think Roc's ABI will be changing anytime soon -- and if it does it will be a major breaking change to coordinate with everyone ... so I think vendoring ZigGlue.roc and tailoring that for your needs is a good idea.
It is possible there are bugs in ZigGlue but it has been quite a while since I've found one ... now that we have roc-fuzz it has been super easy to flush out bugs in refcounting, alignment etc.
I do think there is a lot of improvement we can make for the code it generates... it hasn't had as much polish as it probably needs.
Thank you Scott, I'll give that a try!
Luke, I'm a bit hesitant to vendor the glue code, not because of worries about Roc's ABI changes, but because the host/platform API of my platform will change, and I'd like to regenerate glue in those cases.
Yup, I consider ZigGlue.roc + platform/main.roc + roc to be the origin source of truth (an input) for my source code / build recipe, and thus it's a part of the projects source code & build recipe... in theory one could delete the generated roc_platform_abi.zig (output) file, and only commit the ZigGlue.roc file.
Reproduce-ability for the entire project and regenerating from scratch every time with explicit inputs & an entire complete build recipe from scratch to finish, is a good way to go, so i don't forget to build it, and that it's checked & validated in the build process, if worried about build times, can cache the inputs and outputs, hash them and create a .my-glue-cache cache directory etc. Similar to .zig-cache, or roc's ~/.cache/roc.
Sorry, I mean vendor ZigGlue.roc and tailor that
I also have a CI check that regenerates glue and then git diff --check so I know if anything changes (which basically never does)
Ah sorry, I misunderstood. Yeah, vendoring ZigGlue.roc sounds interesting. I have a kind of weird out-of-tree dependency on that file in the compiler repo, which isn't great. I should just take some time to read that code and get a feel for it, I've treated it as a black box so far.
Last updated: Oct 15 2026 at 17:56 UTC