Type conversion

Contents

The bridge auto-converts every supported Zig type to and from a JS value. The two endpoints are:

  • env.toJs(value) for Zig to JS. Used implicitly on every return value, callback argument, and field assignment.
  • val.to(env, T) for JS to Zig. Used implicitly on every JS argument that is bound to a typed parameter.

You usually never call them directly. They are listed here so you know what is happening underneath, and so you can call them yourself when working with napi.Val directly.

Zig to JS

Zig typeJS result
voidundefined
boolBoolean
?Tinner value or null
comptime_int, comptime_floatNumber
i1..i32, u1..u32Number
i33..i53, u33..u53Number (via f64, safe-integer range)
i54..i64, u54..u64BigInt
f16, f32, f64Number
[]const u8, *const [N:0]u8String
[N]T, []TArray
struct { S, T } (tuple)Array
enumString (tag name, snake to camel)
struct { foo, bar }Object (snake_case fields to camelCase)
napi.Callbackthe original JavaScript function
napi.Valpassthrough
Type with pub fn toJscustom (see below)

JS to Zig

JS typeZig typeNotes
Booleanbool
Numberi1..i32, u1..u32finite integer in the exact Zig range
Numberi33..i53, u33..u53finite integer in the exact Zig range
Numberf16, f32, f64
BigInti54..i64, u54..u64strict, throws RangeError if lossy
null / undefined?T returns null
String[]const u8allocated on env.allocator()
Stringenumaccepts camelCase or snake_case
Array[N]Treal Array with exactly N elements
Array[]Treal Array with dynamic length
Arraystruct { S, T } (tuple)real Array with exactly the tuple length
ObjectstructcamelCase field matching, defaults respected
Functionnapi.Callbackvalidated, throws TypeError if not callable
anynapi.Valpassthrough
anyType with pub fn fromJscustom (see below)

Type mismatches throw a JS TypeError with the actual JS type:

TypeError: expected number, got string
TypeError: invalid enum value for Level: 'foo'

A BigInt that does not fit the target Zig int throws RangeError: bigint out of range for .... To handle lossy values yourself instead of erroring, take a napi.Val parameter and call getBigIntI64 / getBigIntU64.

Number-backed integers are strict: fractional values, NaN, infinities, negative values for unsigned types, and values outside the destination range throw RangeError. The bridge never applies JavaScript's 32-bit wrapping or truncation rules.

Untyped comptime_int constants are emitted as JavaScript numbers only within Number.MIN_SAFE_INTEGER...Number.MAX_SAFE_INTEGER; larger values are rejected at compile time instead of being rounded. Give a larger constant an explicit i64 or u64 type when it should be exposed as a JavaScript BigInt. A Zig slice longer than JavaScript's maximum array length fails with RangeError before any narrowing conversion.

Structs

Struct fields are matched by camelCase own-property name. Inherited values are ignored, so a polluted prototype cannot satisfy a required field or override a Zig default. Zig default values are used when the JS object omits a property:

const Options = struct {
    file_path: []const u8,
    line_count: i32,
    verbose: bool = false,
};

pub fn compile(opts: Options) !void { ... }
compile({ filePath: "main.zig", lineCount: 100 });
// verbose defaults to false

This makes it cheap to add a field: bump the Zig struct, give it a default, no JS callers break.

Automatic struct input must be a JavaScript object or function. null and primitive values are rejected even for struct {}. An own property whose value is undefined follows the same missing-field path as an omitted property.

If every field has a default, a function parameter of that struct type may be omitted or passed as undefined; both forms construct .{} and apply the Zig defaults. Quoted Zig field names are supported and are escaped correctly in generated declarations. Field names must remain unique after snake_case-to-camelCase conversion: ambiguous shapes such as read_file plus readFile are rejected at compile time instead of overwriting data.

Enums

Enums map to and from strings.

const Level = enum { warning, error_level, info };

pub fn log(level: Level, msg: []const u8) void { ... }
addon.log("warning", "disk almost full");
addon.log("errorLevel", "out of memory"); // camelCase also works
addon.log("info", "service started");
addon.log("invalid", "..."); // TypeError: invalid enum value

Both the snake_case and camelCase form of every variant is accepted on the way in. The way out is always camelCase.

Quoted Zig enum tags preserve their exact quote, backslash, and Unicode content in JavaScript and generated TypeScript string literals. Enum tags must remain unique after camelCase conversion; ambiguous tags are rejected at compile time because they could not round-trip losslessly.

Tuples

A Zig anonymous tuple maps to a JS array, and vice versa.

pub fn pair() struct { i32, []const u8 } {
    return .{ 42, "hello" };
}
addon.pair(); // [42, "hello"]

Tuple input must be a real JavaScript Array with exactly one element per tuple field. Fixed arrays follow the same exact-length rule; slices accept any Array length. Array-like objects and extra elements are rejected instead of being silently accepted or ignored.

Optionals and null

?T is T | null on the JS side. null on the Zig side becomes null in JS. undefined on the JS side is treated as null when converting to ?T.

pub fn maybe(env: napi.Env, name: ?[]const u8) ![]const u8 {
    return name orelse "default";
}
addon.maybe("custom"); // "custom"
addon.maybe(null); // "default"
addon.maybe(); // "default"  (undefined treated as null)

Custom conversion

For types the converter cannot handle (unions, opaque handles, tagged shapes), define toJs and fromJs on the type. They take priority over the default field-by-field walk.

const Color = union(enum) {
    rgb: struct { r: u8, g: u8, b: u8 },
    hex: []const u8,

    pub fn toJs(self: Color, env: napi.Env) !napi.Val {
        return switch (self) {
            .rgb => |c| env.toJs(c),
            .hex => |s| env.toJs(s),
        };
    }

    pub fn fromJs(env: napi.Env, val: napi.Val) !Color {
        if ((try val.typeOf(env)) == .string) {
            return .{ .hex = try val.to(env, []const u8) };
        }
        return .{ .rgb = try val.to(env, struct { r: u8, g: u8, b: u8 }) };
    }
};

Both methods are looked up by name. Defining only one is fine; the auto-converter handles the other direction.

When using generated declarations, add a TypeScript override to a custom-converted struct if you want a precise public type:

const CustomPoint = struct {
    pub const _dts = "string";

    x: i32,
    y: i32,

    // toJs / fromJs encode this value as "x,y".
};

_dts is inserted as a trusted, bidirectional TypeScript representation. Use it only when fromJs accepts that representation and toJs produces it. Without _dts, a struct that declares toJs or fromJs is generated as unknown rather than incorrectly exposing its storage fields as the JavaScript representation. If a one-way custom converter differs from the automatic representation in the other direction, keep the generated type unknown or provide a handwritten declaration file with distinct parameter and return types.

Array, slice, and tuple conversion also uses own properties for every index. A hole cannot be filled by an inherited numeric property; an explicit own undefined is still converted normally according to the element type.

Buffers and typed arrays

Buffers are intentionally not in the auto-conversion table. The converter cannot know whether you want a copy, a borrowed slice, or a typed array view. Build them explicitly:

  • env.createBuffer(len) returns { .val, .data } (a JS Buffer plus a writable []u8).
  • env.createArrayBuffer(len) does the same for ArrayBuffer.
  • val.getBufferData(env) returns a []u8 into the existing memory.

See the Env reference for the full list.