Skip to content

Add ergonomic DSL helpers for constructing JS TypedArrays without raw env.createArrayBuffer/createTypedarray boilerplate #19

Description

@nazarhussain

Summary

The current zapi DSL has good wrappers for consuming typed arrays (js.Uint8Array, js.Uint32Array, etc.) and a from(slice) helper for building them from an existing Zig slice.

However, it is still missing an ergonomic way to construct a JS TypedArray by writing directly into freshly allocated V8-backed memory.

This leads to repeated low-level N-API boilerplate in bindings that need to serialize directly into JS-owned buffers:

const size = try view.serializedSize();
var bytes: [*]u8 = undefined;
const buf = try env.createArrayBuffer(size, &bytes);
_ = try view.serializeIntoBytes(bytes[0..size]);
return .{ .val = try env.createTypedarray(.uint8, size, buf, 0) };

This pattern appears frequently in serializer-heavy bindings and is important enough that the DSL should own it.

Problem

Today there are two decent options:

  1. js.Uint8Array.from(slice)

    • ergonomic
    • but requires already having a Zig slice
    • usually implies an extra temporary allocation/copy
  2. raw N-API:

    • env.createArrayBuffer
    • write into returned backing store
    • env.createTypedarray
    • efficient, but noisy and repetitive

What is missing is a DSL-level constructor that combines:

  • V8 allocation
  • writable slice access
  • typed-array wrapper return

Goals

Add a first-class DSL API for allocating typed arrays and filling them directly from Zig code, without raw N-API boilerplate.

This should:

  • reduce repeated binding code
  • preserve zero-extra-copy construction into JS-owned memory
  • keep typed-array creation consistent across bindings
  • work especially well for serialization workflows

Non-goals

  • replacing from(slice) for simple cases where a Zig slice already exists
  • solving generic typed JS arrays like Array<Uint8Array> or Array<T>
  • changing existing argument-side typed-array wrappers

Proposed API

Option A: initWithLength style constructor

Add a constructor on each typed-array wrapper that allocates JS-owned backing storage and exposes it to a callback:

pub fn initWithLength(len: usize, fill: anytype) !Self

Example use:

return try js.Uint8Array.initWithLength(size, struct {
    fn fill(out: []u8) !void {
        _ = try view.serializeIntoBytes(out);
    }
}.fill);

Or:

return try js.Uint32Array.initWithLength(count, struct {
    fn fill(out: []u32) !void {
        @memcpy(out, source);
    }
}.fill);

Behavior:

  • allocates a JS ArrayBuffer of the correct size
  • creates the corresponding TypedArray view
  • passes a writable typed slice to fill
  • returns the concrete wrapper type

Option B: split allocation and writable access

Add an allocation constructor returning the typed array plus direct writable access:

pub fn createWithLength(len: usize) !Self
pub fn toSlice(self: Self) ![]Element

toSlice() already exists on the read side, but there is no ergonomic allocation path.

Example:

var arr = try js.Uint8Array.createWithLength(size);
const out = try arr.toSlice();
_ = try view.serializeIntoBytes(out);
return arr;

This is simpler and probably the most composable API.

Option C: specialized writer helper

Add a convenience helper specifically for byte serializers:

pub fn fromWriter(len: usize, writer: anytype) !Self

Example:

return try js.Uint8Array.fromWriter(size, struct {
    fn write(out: []u8) !usize {
        return view.serializeIntoBytes(out);
    }
}.write);

This is nice for serialization-heavy code but narrower than A/B.

Recommended design

I would recommend Option B as the core primitive:

pub fn createWithLength(len: usize) !Self
pub fn toSlice(self: Self) ![]Element

Why:

  • minimal API surface
  • general-purpose
  • readable
  • supports serializers, encoders, computed fills, memcpy, etc.
  • composes naturally with existing wrapper model

Then optionally add Option C later as sugar for common serialization cases.

Suggested semantics

For TypedArray(Element, array_type):

  • createWithLength(len) allocates a JS ArrayBuffer of len * @sizeOf(Element)
  • creates the JS TypedArray view
  • returns Self

toSlice() on a newly created typed array should:

  • return a writable []Element
  • point directly at V8 backing memory
  • preserve the same lifetime caveats already documented for typed-array backing stores

Potential implementation sketch

Current from(slice) already does most of the work:

pub fn from(slice: []const Element) Self {
    const e = context.env();
    const byte_len = slice.len * @sizeOf(Element);
    var buf_ptr: [*]u8 = undefined;
    const arraybuffer = e.createArrayBuffer(byte_len, &buf_ptr) catch
        @panic("TypedArray.from: createArrayBuffer failed");
    const dest: [*]Element = @ptrCast(@alignCast(buf_ptr));
    @memcpy(dest[0..slice.len], slice);
    const val = e.createTypedarray(array_type, slice.len, arraybuffer, 0) catch
        @panic("TypedArray.from: createTypedarray failed");
    return .{ .val = val };
}

createWithLength could be the same allocation path without the copy:

pub fn createWithLength(len: usize) !Self {
    const e = context.env();
    const byte_len = len * @sizeOf(Element);
    var buf_ptr: [*]u8 = undefined;
    const arraybuffer = try e.createArrayBuffer(byte_len, &buf_ptr);
    const val = try e.createTypedarray(array_type, len, arraybuffer, 0);
    return .{ .val = val };
}

Then callers can do:

var arr = try js.Uint8Array.createWithLength(size);
const out = try arr.toSlice();
_ = try view.serializeIntoBytes(out);
return arr;

Error model

One design choice to decide explicitly:

  • current from(slice) panics on allocation/N-API failure
  • this proposed constructor is likely better as !Self, not panic-based

Reason:

  • it is more suitable for binding code paths already using try
  • it avoids mixing panic-style constructors with fallible serializers
  • it matches how most binding code is already written

So I recommend:

  • keep from(slice) as-is for now for backwards compatibility
  • make createWithLength(len) !Self
  • optionally add a panic-based variant later if desired

Examples from real binding pain points

This is especially useful for code that currently does manual env.createTypedarray in many places, e.g.:

  • serializing SSZ views into Uint8Array
  • creating arrays of roots/proofs
  • constructing Uint32Array outputs from cache/index structures
  • exposing validator/committee/index data efficiently

Without this helper, every binding re-implements the same allocation ceremony.

Acceptance criteria

  • zapi exposes an ergonomic typed-array allocation helper in the DSL
  • callers can allocate JS-owned typed arrays without directly calling env.createArrayBuffer / env.createTypedarray
  • returned value is the concrete wrapper type (js.Uint8Array, js.Uint32Array, etc.)
  • caller can write directly into V8-backed memory
  • API is documented with the same lifetime caveats already present for toSlice()
  • at least one integration test covers:
    • allocate typed array
    • fill via slice
    • return to JS
    • JS sees correct contents and correct TypedArray type

Nice-to-have follow-ups

  • fromWriter(len, writer) sugar for byte serializers
  • createZeroedWithLength(len) if zero-init semantics matter
  • equivalent ergonomics for js.Array element construction helpers in the future
  • longer-term: generic typed array/object array abstractions like js.ArrayOf(T)

Why this matters

This is not just cosmetic. It fills an important gap in the DSL:

  • from(slice) is ergonomic but copy-oriented
  • raw env calls are efficient but not ergonomic
  • bindings need a first-class “allocate in JS, write from Zig” path

That path is common enough that it should be part of the public DSL rather than repeated ad hoc in every binding.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type: FeatureAdded to issues and PRs to identify that the change is a new feature.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions