Skip to content

Feature: support returning a struct of class instances as a JS object #23

@nazarhussain

Description

@nazarhussain

Summary

js.convertReturn currently handles two return shapes from DSL functions:

  1. A single class type — auto-materialized into a JS instance.
  2. A DSL-wrapper type (anything with val: napi.Value) — passed through directly.

There is no built-in path for returning a JS object whose fields are themselves materialized class instances. This forces consumers to hand-build the object with createObject() + per-field js.convertReturn + setNamedProperty, losing the DSL's auto-marshalling ergonomics.

Motivation

Real-world example from BLS signature aggregation: the function needs to return { pk: PublicKey, sig: Signature } where both are DSL-wrapped class types. The cleanest currently-possible code:

pub fn aggregateWithRandomness(sets: js.Array) !js.Value {
    // ... compute result_pk: NativePublicKey, result_sig: NativeSignature ...
    const env = js.env();
    const result = try env.createObject();
    try result.setNamedProperty("pk", .{
        .env = env.env,
        .value = js.convertReturn(PublicKey, .{ .raw = result_pk }, env.env),
    });
    try result.setNamedProperty("sig", .{
        .env = env.env,
        .value = js.convertReturn(Signature, .{ .raw = result_sig }, env.env),
    });
    return .{ .val = result };
}

The return type !js.Value carries no information about the JS shape; readers must consult the doc comment.

Why none of today's shapes fit

  • Returning a bare struct { pk: PublicKey, sig: Signature } hits @compileError(\"convertReturn: unsupported return type ...\") — it's neither a class nor a DSL wrapper.
  • !js.Object(struct { pk: PublicKey, sig: Signature }) compiles, but the inner struct is purely cosmetic — Object(T).set / .get would fail to instantiate because they assume each field has a .val: napi.Value accessor; class types expose .raw instead. Zig's lazy method-body resolution lets the annotation slip through unchecked, so it doesn't validate the actual JS object — misleading.
  • !js.Object(struct { pk: js.Value, sig: js.Value }) works with .set(...) but throws away the per-field type information and still needs manual js.convertReturn to wrap each class instance.

Proposed feature

Extend convertReturn (or add a sibling primitive) so that a Zig struct whose fields are class types or DSL wrappers can be returned from a DSL function, and the runtime auto-builds a JS object with each field materialized appropriately.

const Result = struct { pk: PublicKey, sig: Signature };

pub fn aggregateWithRandomness(sets: js.Array) !Result {
    // ...
    return .{
        .pk = .{ .raw = result_pk },
        .sig = .{ .raw = result_sig },
    };
}

Semantics: comptime iterate the struct fields, run convertReturn per field, assemble into a JS object via napi_create_object + napi_set_named_property. Mixed fields (class, DSL wrapper, raw napi.Value) should all work uniformly.

This would close the gap with typical N-API binding ergonomics (structured returns are common for crypto, serialization, multi-result helpers) and let the example return type be honest about its shape:

pub fn aggregateWithRandomness(sets: js.Array) !struct { pk: PublicKey, sig: Signature }

Workaround today

A project-local helper closes the gap but arguably belongs in the DSL itself:

inline fn buildJsObject(comptime Shape: type, value: Shape) !js.Value {
    const e = js.env();
    const obj = try e.createObject();
    inline for (@typeInfo(Shape).@\"struct\".fields) |field| {
        const napi_val = napi.Value{
            .env = e.env,
            .value = js.convertReturn(field.type, @field(value, field.name), e.env),
        };
        try obj.setNamedProperty(field.name ++ \"\", napi_val);
    }
    return .{ .val = obj };
}

Encountered while migrating lodestar-z's blst NAPI bindings to the high-level DSL.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions