Writing Zig Native Node.js Addons Without N-API Boilerplate

Writing native Node.js addons in Zig shouldn't mean drowning in N-API boilerplate. Enter zapi: a tool featuring a new high-level DSL that automatically turns plain Zig functions into type-safe JavaScript APIs at compile time, blending low-level control with a seamless developer experience.

Writing Zig Native Node.js Addons Without N-API Boilerplate

Zig is becoming an increasingly attractive choice for building native Node.js modules. Compared to traditional C++ addons, it offers:

  • simpler cross-platform builds
  • better compile-time tooling
  • stronger safety guarantees than C
  • lower-level control when performance matters

But the JavaScript binding layer still feels stuck in 2015. Even the simplest native function requires repetitive plumbing: manual argument extraction, manual type checking, manual callback registration. For every useful line of logic, you write several lines of binding code.

While building native infrastructure, we kept running into this problem repeatedly inside zapi, a Zig N-API wrapper library and CLI for building and publishing cross-platform Node.js native addons.

We wanted a way to preserve full control over Node's N-API when needed while dramatically simplifying the common path.

The result is a new high-level DSL introduced in zapi that lets you write plain Zig functions and automatically turns them into JavaScript APIs at compile time.

The problem with raw N-API

Node's N-API is a C interface. Zig can call it directly via @cImport, but the developer experience is extremely manual. At the lowest level, you work with opaque napi_value handles, check napi_status return codes manually, and manage everything yourself:

Even a simple add(a, b) function requires:

const c = @import("zapi").c;

fn raw_add(
    env: c.napi_env,
    info: c.napi_callback_info,
) callconv(.C) c.napi_value {
    // Extract arguments from callback info
    var args: [2]c.napi_value = undefined;
    var argc: usize = 2;
    _ = c.napi_get_cb_info(env, info, &argc, &args,

    // Convert JS values to Zig integers
    var a: i32 = undefined;
    var b: i32 = undefined;
    _ = c.napi_get_value_int32(env, args[0], &a);
    _ = c.napi_get_value_int32(env, args[1], &b);

    // Create the result
    var result: c.napi_value = undefined;
    _ = c.napi_create_int32(env, a + b, &result);
    return result;
}

And then you still need to manually register that function.

fn register(env: c.napi_env, exports: c.napi_value) c.napi_value {
    var fn_value: c.napi_value = undefined;
    _ = c.napi_create_function(env, "add", 3, raw_add, null, &fn_value);
    _ = c.napi_set_named_property(env, exports, "add", fn_value);
    return exports;
}

This works, but every function follows the same tedious pattern: extract arguments by index, convert each one, check status codes, build the return value, register manually. Miss a status check and you get undefined behavior. Miscount an argument index and you get silent data corruption.

It's repetitive, error-prone, and difficult to scale across larger native modules.

The evolution: raw API → wrapper → DSL

Rather than forcing developers into one abstraction level, zapi now provides three layers.

Layer 1: Raw C N-API

While it has its own problems, it remains the best method when you need full access to a Node's internals. It provides maximum control and verbosity.

Layer 2: Zig-friendly N-API wrapper

zapi wraps raw C handles into Zig-native APIs. napi_env becomes zapi.Env with methods. napi_value becomes zapi.Value with type-specific extractors. Status codes become Zig error unions. The same function:

const zapi = @import("zapi");

fn add(env: zapi.Env, cb: zapi.CallbackInfo(2)) !zapi.Value {
    const a = try cb.arg(0).getValueInt32();
    const b = try cb.arg(1).getValueInt32();
    return try env.createInt32(a + b);
}

This removes:

  • manual status code handling
  • raw pointer manipulation
  • repetitive conversion logic

But you still manually register exports and extract arguments.

fn registerModule(env: zapi.Env, module: zapi.Value) anyerror!void {
    try module.setNamedProperty("add", try env.createFunction(
        "add", 2, add, null,
    ));
}

comptime {
    zapi.module.register(registerModule);
}

This is already a big improvement. CallbackInfo(2) gives you a typed container for exactly two arguments. getValueInt32() returns !i32 – the error union replaces manual status checks. cb.arg(0) returns a zapi.Value with methods, not an opaque pointer.

But you still write the same callback signature for every function: fn(env, cb) !Value. You still extract arguments by positional index. You still register each export by hand.

Layer 3: js high-level DSL

This is where things become significantly simpler. The DSL layer eliminates all of the extra work.

You write a plain Zig function whose parameters and return type are JavaScript-aligned wrappers:

const js = @import("zapi").js;

pub fn add(a: js.Number, b: js.Number) !js.Number {
    return js.Number.from(try a.toI32() + try b.toI32());
}

comptime {
    js.exportModule(@This(), .{});
}

That's the entire thing. The DSL rules for functions:

  • All pub declarations in the module are scanned at compile time
  • Functions with DSL-typed parameters are identified and exported automatically
  • No callback info, no positional argument indices, no manual registration

At compile time, zapi automatically discovers compatible public functions and generates the necessary N-API glue.

Side by side

Here is what the progression looks like for each concern:

Extracting an integer argument:

// Layer 1: Raw C
var a: i32 = undefined;
_ = c.napi_get_value_int32(env, args[0], &a);

// Layer 2: napi wrapper
const a = try cb.arg(0).getValueInt32();

// Layer 3: js DSL
// (it's just `a: js.Number` in the function signature)

Returning a value:

// Layer 1: Raw C
var result: c.napi_value = undefined;
_ = c.napi_create_int32(env, a + b, &result);
return result;

// Layer 2: napi wrapper
return try env.createInt32(a + b);

// Layer 3: js DSL
return js.Number.from(a + b);

Registering exports:

// Layer 1: Raw C
_ = c.napi_create_function(env, "add", 3, raw_add, null, &fn_value);
_ = c.napi_set_named_property(env, exports, "add", fn_value);

// Layer 2: napi wrapper
try module.setNamedProperty("add", try env.createFunction("add", 2, add, null));

// Layer 3: js DSL
comptime { js.exportModule(@This(), .{}); }

Classes at three levels

The difference becomes even more dramatic with classes. Here is a simple Counter class at each layer.

Layer 1 & 2: Manual class wiring

Even at the napi wrapper level, classes require significant ceremony:

const Counter = struct { count: i32 = 0 };

fn Counter_ctor(env: zapi.Env, cb: zapi.CallbackInfo(0)) !zapi.Value {
    const counter = try allocator.create(Counter);
    counter.* = .{};
    _ = try env.wrap(cb.this(), Counter, counter, Counter_finalize, null, null);
    return cb.this();
}

fn Counter_increment(env: zapi.Env, cb: zapi.CallbackInfo(0)) !zapi.Value {
    const counter = try env.unwrap(Counter, cb.this());
    counter.count += 1;
    return try env.getUndefined();
}

fn Counter_getCount(env: zapi.Env, cb: zapi.CallbackInfo(0)) !zapi.Value {
    const counter = try env.unwrap(Counter, cb.this());
    return try env.createInt32(counter.count);
}

fn Counter_finalize(_: zapi.Env, counter: *Counter, _: ?*anyopaque) void {
    allocator.destroy(counter);
}

And the registration:

try module.setNamedProperty("Counter", try env.defineClass(
    "Counter", 0, Counter_ctor, null,
    &[_]zapi.c.napi_property_descriptor{
        .{ .utf8name = "increment", .method = zapi.wrapCallback(0, Counter_increment) },
        .{ .utf8name = "getCount",  .method = zapi.wrapCallback(0, Counter_getCount) },
    },
));

You manually define the constructor, manually wrap/unwrap native objects from this, manually build property descriptor arrays, and manually register the class. Every method repeats the env.unwrap(Counter, cb.this()) pattern.

Layer 3: DSL class

The same Counter with the DSL:

pub const Counter = struct {
    pub const js_meta = js.class(.{});

    count: i32,

    pub fn init() Counter {
        return .{ .count = 0 };
    }

    pub fn deinit(self: *Counter) void {
        // cleanup, called by GC
    }

    pub fn increment(self: *Counter) void {
        self.count += 1;
    }

    pub fn getCount(self: Counter) js.Number {
        return js.Number.from(self.count);
    }
};

That's it – the same Counter, the same methods. The DSL rules for classes:

  • init becomes the JavaScript constructor
  • deinit becomes the destructor, called when the garbage collector reclaims the object
  • Functions with a self parameter become instance methods
  • Functions without self become static methods
  • All wrapping, unwrapping, and property descriptor generation happens at compile time

On the JavaScript side:

const counter = new Counter();
counter.increment();
counter.getCount(); // 1

What the DSL unlocks

By eliminating manual N-API boilerplate, the DSL unlocks native classes, type-safe APIs, natural error handling, and JavaScript-friendly APIs directly from plain Zig code.

Type system

The DSL provides wrapper types that mirror JavaScript's type system. Each is a zero-cost struct wrapping a raw napi.Value:

These wrappers mirror JavaScript primitives and objects:

  • Primitives: Number, String, Boolean, BigInt, Date
  • Collections: Array, Object(T), Function
  • Typed Arrays: Uint8Array, Int32Array, Float64Array, and all 11 standard variants
  • Async: Promise(T)
  • Escape hatch: Value – an untyped wrapper with runtime narrowing methods

Each type provides conversion methods in both directions. Number.from(42) creates a JavaScript number from a Zig integer. number.toI32() extracts it back.

These wrappers are intentionally small and predictable.

1    js.Number.from(42)
2    try number.toI32()
3

This keeps Zig code readable while preserving type safety.

Typed objects

For structured data, Object(T) lets you define a Zig struct whose fields are DSL types, then read/write JavaScript objects in one call:

const Config = struct {
    width: js.Number,
    height: js.Number,
    title: js.String,
};

pub fn configure(config: js.Object(Config)) !js.String {
    const c = try config.get();
    const w = try c.width.toI32();
    // ...
}

Optional parameters

Zig's optional types map naturally to JavaScript's undefined:

pub fn translate(dx: js.Number, dy: ?js.Number) !Point {
    const offset_y: i32 = if (dy) |n| n.assertI32() else 0;
    // ...
}

The DSL rules for optional parameters:

  • ?js.Number accepts an omitted argument or an explicit undefined from JavaScript
  • Both cases arrive as Zig null
  • Required parameter count is derived automatically from the number of non-optional parameters

Automatic type validation

One of the most practical benefits is automatic runtime type checking. With raw N-API or even the wrapper layer, passing the wrong type often results in a cryptic crash or a generic "Native callback failed" error. With the DSL, argument types are validated before your function is called:

// JavaScript
shuffleList("not an array", seed, rounds, true);
// Throws: TypeError: Argument 1 must be a Uint32Array

The error message is generated automatically from the parameter type. No manual validation code needed.

Error handling

Zig's error unions map cleanly to JavaScript exceptions:

pub fn safeDivide(a: js.Number, b: js.Number) !js.Number {
    const divisor = b.assertI32();
    if (divisor == 0) return error.DivisionByZero;
    return js.Number.from(@divTrunc(try a.toI32(), divisor));
}

The DSL rules for error handling:

  • A return type of !T enables the function to throw JavaScript errors
  • Zig error.DivisionByZero becomes throw new Error("DivisionByZero") on the JavaScript side
  • Any Zig error name is forwarded as the JavaScript error message

Optional returns

Zig's optional types map to JavaScript undefined:

pub fn findValue(arr: js.Array, target: js.Number) !?js.Number {
    // return null → JavaScript receives undefined
}

The DSL rules for optional returns:

  • A return type of ?T allows the function to return undefined to JavaScript
  • Returning Zig null becomes JavaScript undefined
  • !?T combines both – the function can throw or return undefined

Getters and setters

Properties are declared in js_meta and backed by getter/setter functions:

pub const Settings = struct {
    pub const js_meta = js.class(.{
        .properties = .{
            .volume = js.prop(.{ .get = true, .set = true }),
            .label = js.prop(.{ .get = true, .set = false }),  // read-only
        },
    });

    _volume: i32,

    pub fn init() Settings { return .{ ._volume = 50 }; }
    pub fn volume(self: Settings) js.Number { return js.Number.from(self._volume); }
    pub fn setVolume(self: *Settings, val: js.Number) !void { self._volume = try val.toI32(); }
    pub fn label(self: Settings) js.String { return js.String.from("default"); }
};

Static factories and auto-materialization

When a static method returns the class type, the DSL automatically materializes a new JavaScript instance:

pub const Point = struct {
    pub const js_meta = js.class(.{});
    x: i32,
    y: i32,

    pub fn init() Point { return .{ .x = 0, .y = 0 }; }

    pub fn create(x: js.Number, y: js.Number) Point {
        return .{ .x = x.assertI32(), .y = y.assertI32() };
    }
};
const p = Point.create(10, 20); // Returns a proper Point instance
p instanceof Point; // true

The DSL rules for factories:

  • When a static method's return type is the class itself, the DSL auto-materializes a new JavaScript instance
  • No factory annotations or explicit wrapping needed
  • The returned object passes instanceof checks correctly

You can still drop down a layer

A common concern with high-level abstractions is flexibility. The DSL does not remove low-level access. You can still drop down to raw N-API whenever necessary:

pub fn getTypeOf(val: js.Value) !js.String {
    const e = js.env();  // access the raw napi.Env
    const type_str = try e.typeOf(val.val);
    return js.String.from(type_str);
}

The three layers are designed to coexist, not to replace each other. You can access the raw napi.Env from anywhere inside a DSL function.

Both layers are available from the same import: @import("zapi").js for the DSL and @import("zapi").napi for the raw bindings. You can convert straightforward bindings to the DSL while leaving complex edge cases on the wrapper API. The layers compose without friction.

How it works under the hood

The DSL is mostly powered by Zig's comptime – compile-time code execution. When you write js.exportModule(@This(), .{}), the compiler:

  1. Reflects on your module's pub declarations
  2. Validates that exported functions have DSL-typed parameters
  3. Generates N-API callback wrappers for each function
  4. Generates class definitions with constructors, finalizers, and property descriptors
  5. Registers everything as module exports

No code generation step, no macros, no build-time tooling. The generated callbacks are fully inlined and optimized by the Zig compiler. The DSL types themselves are zero-cost – a js.Number is just a struct holding one napi_value pointer.

There is one piece that cannot be resolved at compile time: class instance materialization. When a static factory or a method returns a class type, the DSL needs to call the JavaScript constructor to create a proper instance that passes instanceof checks. Since constructor references only exist at runtime (they are created by N-API when the module loads), the DSL maintains a small runtime registry that stores constructor references per class type. This is the only runtime bookkeeping the DSL performs – a mutex-protected linked list, consulted only when materializing class instances. Everything else is zero-cost comptime.

Why We Built zapi

We built this while working on performance-sensitive infrastructure where repetitive binding code was slowing development.

zapi offers two layers of abstraction on top of Node's raw C N-API: a Zig-idiomatic wrapper for ergonomic low-level work, and a high-level DSL that turns plain Zig functions into JavaScript exports at compile time. You pick the level that fits the problem, and you can mix them freely within the same module.

The goal was never to hide N-API completely. The goal was to make the common path dramatically easier while preserving escape hatches for advanced use cases.

If you are building performance-sensitive Node.js modules in Zig – database bindings, crypto libraries, compression engines, or any native addon where binding boilerplate is slowing you down – give zapi a try. The DSL layer is available starting from version 1.0.0 and is already powering production bindings in lodestar-z, where we are migrating Lodestar, ChainSafe's Ethereum consensus client, from JavaScript to Zig.

Lodestar’s next chapter: Blending Zig and JavaScript
Lodestar’s future: transitioning from a pure JavaScript client focused on browser compatibility to a performance-first, hybrid JavaScript and Zig architecture.

About ChainSafe

ChainSafe is a leading blockchain research and development firm specializing in protocol engineering, infrastructure development & operations, and co-development.

ChainSafe creates solutions for developers and teams across web3. As part of our mission to build accessible, improved tooling for developers, ChainSafe embodies an open source, community-guided ethos to advance the future of the internet.

Website | Youtube | Twitter | Linkedin | GitHub