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:
-
js.Uint8Array.from(slice)
- ergonomic
- but requires already having a Zig slice
- usually implies an extra temporary allocation/copy
-
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.
Summary
The current zapi DSL has good wrappers for consuming typed arrays (
js.Uint8Array,js.Uint32Array, etc.) and afrom(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:
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:
js.Uint8Array.from(slice)raw N-API:
env.createArrayBufferenv.createTypedarrayWhat is missing is a DSL-level constructor that combines:
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:
Non-goals
from(slice)for simple cases where a Zig slice already existsArray<Uint8Array>orArray<T>Proposed API
Option A:
initWithLengthstyle constructorAdd a constructor on each typed-array wrapper that allocates JS-owned backing storage and exposes it to a callback:
Example use:
Or:
Behavior:
fillOption B: split allocation and writable access
Add an allocation constructor returning the typed array plus direct writable access:
toSlice()already exists on the read side, but there is no ergonomic allocation path.Example:
This is simpler and probably the most composable API.
Option C: specialized writer helper
Add a convenience helper specifically for byte serializers:
Example:
This is nice for serialization-heavy code but narrower than A/B.
Recommended design
I would recommend Option B as the core primitive:
Why:
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 oflen * @sizeOf(Element)SelftoSlice()on a newly created typed array should:[]ElementPotential implementation sketch
Current
from(slice)already does most of the work:createWithLengthcould be the same allocation path without the copy:Then callers can do:
Error model
One design choice to decide explicitly:
from(slice)panics on allocation/N-API failure!Self, not panic-basedReason:
trySo I recommend:
from(slice)as-is for now for backwards compatibilitycreateWithLength(len) !SelfExamples from real binding pain points
This is especially useful for code that currently does manual
env.createTypedarrayin many places, e.g.:Uint8ArrayUint32Arrayoutputs from cache/index structuresWithout this helper, every binding re-implements the same allocation ceremony.
Acceptance criteria
env.createArrayBuffer/env.createTypedarrayjs.Uint8Array,js.Uint32Array, etc.)toSlice()Nice-to-have follow-ups
fromWriter(len, writer)sugar for byte serializerscreateZeroedWithLength(len)if zero-init semantics matterjs.Arrayelement construction helpers in the futurejs.ArrayOf(T)Why this matters
This is not just cosmetic. It fills an important gap in the DSL:
from(slice)is ergonomic but copy-orientedThat path is common enough that it should be part of the public DSL rather than repeated ad hoc in every binding.