From 068a33a212c0bcb7f7d82f24a505b280480a5a0c Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 19 Oct 2026 12:09:47 +0200 Subject: [PATCH 1/5] fix(napi): set sign_bit before reporting getValueBigintWords overflow napi fills the low-order words and the sign even when the BigInt needs more words than the buffer holds. Setting sign_bit before returning error.Overflow lets callers catch the error and read the truncated value. Co-Authored-By: Claude Opus 5.5 --- src/Value.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Value.zig b/src/Value.zig index 5912f98..0473c68 100644 --- a/src/Value.zig +++ b/src/Value.zig @@ -274,7 +274,10 @@ pub fn getValueBigintUint64(self: Value, lossless: ?*bool) NapiError!u64 { /// /// Returns `error.Overflow` if the BigInt needs more words than `words` can hold: /// napi sets the out `word_count` to the *required* count, which may exceed the -/// buffer, so slicing by it unchecked would read out of bounds. +/// buffer, so slicing by it unchecked would read out of bounds. napi still fills +/// `words` with the low-order words and `sign_bit` is set before the error is +/// returned, so callers that want truncation semantics may catch `error.Overflow` +/// and read the buffer directly. /// /// NOTE: napi's C entry takes `int*` (4-byte aligned). Casting a u1 to int* is UB, since that /// is 4-bytes aligned. We use a local `c_int` for the napi call and narrow back to `u1` for the caller. @@ -286,9 +289,9 @@ pub fn getValueBigintWords(self: Value, sign_bit: ?*u1, words: []u64) (NapiError try status.check( c.napi_get_value_bigint_words(self.env, self.value, &raw_sign, &word_count, words.ptr), ); - if (word_count > words.len) return error.Overflow; // napi guarantees raw_sign ∈ {0, 1} if (sign_bit) |s| s.* = @intCast(raw_sign); + if (word_count > words.len) return error.Overflow; return words[0..word_count]; } From c1fd0d37205c0b369e3fa97f79adc62254761d25 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 19 Oct 2026 12:09:47 +0200 Subject: [PATCH 2/5] docs(js): document N-API Number conversion semantics Co-Authored-By: Claude Opus 5.5 --- src/js/number.zig | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/js/number.zig b/src/js/number.zig index aca3b0b..e38feeb 100644 --- a/src/js/number.zig +++ b/src/js/number.zig @@ -27,19 +27,24 @@ pub const Number = struct { /// Attempts to convert the JavaScript number to a Zig `i32`. /// - /// Returns an error if the conversion fails (e.g., number is too large). + /// Follows N-API int32 conversion semantics: finite values outside the + /// 32-bit range are truncated to the equivalent low 32 bits, and non-finite + /// values (`NaN`, `+Infinity`, `-Infinity`) become 0. + /// + /// Returns an error if the underlying value is not a number or the N-API + /// call fails. pub fn toI32(self: Number) !i32 { return self.val.getValueInt32(); } /// Attempts to convert the JavaScript number to a Zig `u32`. /// - /// Safety: The underlying napi implementation does not reject numbers out of - /// the range [0, 2^32). The caller is responsible for bounds checking. - /// - /// Returns an error if the given `self.val` is not a number. + /// Follows N-API uint32 conversion semantics: finite values outside the + /// 32-bit range are truncated to the equivalent low 32 bits, and non-finite + /// values (`NaN`, `+Infinity`, `-Infinity`) become 0. /// - /// Source: https://tc39.es/ecma262/#sec-touint32 + /// Returns an error if the underlying value is not a number or the N-API + /// call fails. pub fn toU32(self: Number) !u32 { return self.val.getValueUint32(); } @@ -98,7 +103,13 @@ pub const Number = struct { /// Attempts to convert the JavaScript number to a Zig `i64`. /// - /// Returns an error if the conversion fails (e.g., number is too large). + /// Follows N-API int64 conversion semantics: non-finite values (`NaN`, + /// `+Infinity`, `-Infinity`) become 0. Values outside JavaScript's safe + /// integer range may lose precision, and finite values outside the `i64` + /// range are clamped by Node to `minInt(i64)`/`maxInt(i64)`. + /// + /// Returns an error if the underlying value is not a number or the N-API + /// call fails. pub fn toI64(self: Number) !i64 { return self.val.getValueInt64(); } From ccaf068b914bca57cd67e05f261390a98122a9d5 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 19 Oct 2026 12:09:52 +0200 Subject: [PATCH 3/5] feat(js): add BigInt.fromWords for word-level construction Co-Authored-By: Claude Opus 5.5 --- src/js/bigint.zig | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/js/bigint.zig b/src/js/bigint.zig index f27d0fd..d3e3fdc 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -84,8 +84,7 @@ pub const BigInt = struct { /// Creates a JavaScript `BigInt` from a Zig integer value. /// /// Accepts `i64`, `u64`, and `comptime_int` values within this range. - /// For `i128`/`u128` values, use `fromWords` (not currently exposed but - /// available at the `napi.Env` level). + /// For `i128`/`u128` values, use `fromWords`. /// /// Panics if N-API operations fail (e.g., invalid environment) or for Zig /// integer types larger than `u64` that cannot be converted to `i64`/`u64`. @@ -118,6 +117,23 @@ pub const BigInt = struct { return .{ .val = val }; } + /// Creates a JavaScript `BigInt` from a sign bit and word array. + /// + /// `sign_bit` is 0 for non-negative, 1 for negative. `words` is the + /// little-endian unsigned magnitude. The resulting BigInt equals + /// `(sign_bit == 1 ? -1 : 1) * sum(words[i] << (64 * i))`. + /// + /// Use this for constructing BigInts whose magnitude exceeds `u64` (e.g., + /// i128 or larger). For `i64`/`u64` values, prefer `BigInt.from`. + /// + /// Panics if N-API operations fail (e.g., invalid environment). + pub fn fromWords(sign_bit: u1, words: []const u64) BigInt { + const e = context.env(); + const val = e.createBigintWords(sign_bit, words) catch + @panic("BigInt.fromWords: createBigintWords failed"); + return .{ .val = val }; + } + /// Returns the underlying `napi.Value` representation of this JavaScript BigInt. pub fn toValue(self: BigInt) napi.Value { return self.val; From 17fba3f1885c62bb38feaed149e63b95e378fb24 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 19 Oct 2026 12:10:09 +0200 Subject: [PATCH 4/5] fix(js)!: make BigInt conversions exact by default BigInt.toI64 and BigInt.toU64 now take no arguments and return error.InvalidArg when the value does not fit, instead of silently returning the low bits. The previous lossy behaviour moves to toI64LowBits and toU64LowBits, which keep the optional lossless flag. BigInt.toI128 now returns error.InvalidArg (was error.Overflow) for out-of-range values, and toI128LowBits adds BigInt.asIntN(128) truncation semantics. BREAKING CHANGE: BigInt.toI64(lossless) and BigInt.toU64(lossless) are now toI64() and toU64() and reject out-of-range values. Use toI64LowBits(lossless) / toU64LowBits(lossless) for truncation. BigInt.toI128 reports out-of-range values as error.InvalidArg. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- examples/js_dsl/mod.zig | 3 +- src/js/bigint.zig | 123 +++++++++++++++++++++++++++++++--------- 3 files changed, 98 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 806dca9..4ed2346 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Code paths that cannot accept or return DSL classes pass | `Number` | `number` | `toI32()`, `toF64()`, `assertI32()`, `from(anytype)` | | `String` | `string` | `toSlice(buf)`, `toOwnedSlice(alloc)`, `len()`, `from([]const u8)` | | `Boolean` | `boolean` | `toBool()`, `assertBool()`, `from(bool)` | -| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `from(anytype)` | +| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `to*LowBits()`, `from(anytype)`, `fromWords(sign, words)` | | `Date` | `Date` | `toTimestamp()`, `from(f64)` | | `Array` | `Array` | `get(i)`, `getNumber(i)`, `length()`, `set(i, val)` | | `Object(T)` | `object` | `get()`, `set(value)` — `T` fields must be DSL types | diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index f01b73e..6f21db2 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -107,8 +107,7 @@ pub fn reverseString(s: String) !String { /// Double a BigInt value. pub fn doubleBigInt(n: BigInt) !BigInt { - var lossless: bool = false; - const val = try n.toI64(&lossless); + const val = try n.toI64(); return BigInt.from(val * 2); } diff --git a/src/js/bigint.zig b/src/js/bigint.zig index d3e3fdc..f6cdd9b 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -14,69 +14,138 @@ pub const BigInt = struct { if ((try val.typeof()) != .bigint) return error.TypeMismatch; } - /// Attempts to convert the JavaScript BigInt to a Zig `i64`. + /// Attempts to convert the JavaScript BigInt exactly to a Zig `i64`. /// - /// If `lossless` is provided, it will be set to `true` if the BigInt can be - /// represented exactly as an `i64`, and `false` otherwise (e.g., if the - /// BigInt is too large or too small for `i64`). - /// Returns an error if the conversion fails or the `napi_env` is invalid. - pub fn toI64(self: BigInt, lossless: ?*bool) !i64 { + /// Returns `error.InvalidArg` if the value is outside the `i64` range. Use + /// `toI64LowBits` when low-64-bit conversion is intended. + pub fn toI64(self: BigInt) !i64 { + var lossless = false; + const value = try self.toI64LowBits(&lossless); + if (!lossless) return error.InvalidArg; + return value; + } + + /// Converts the JavaScript BigInt to the low 64 bits as a Zig `i64`. + /// + /// If `lossless` is provided, it is set to whether the returned `i64` + /// represents the original BigInt exactly. Out-of-range values still return + /// their low 64 bits, matching N-API BigInt conversion semantics, but set + /// `lossless` to false. + pub fn toI64LowBits(self: BigInt, lossless: ?*bool) !i64 { return self.val.getValueBigintInt64(lossless); } - /// Attempts to convert the JavaScript BigInt to a Zig `u64`. + /// Attempts to convert the JavaScript BigInt exactly to a Zig `u64`. /// - /// If `lossless` is provided, it will be set to `true` if the BigInt can be - /// represented exactly as a `u64`, and `false` otherwise. - /// Returns an error if the conversion fails (e.g., BigInt is negative) or - /// the `napi_env` is invalid. - pub fn toU64(self: BigInt, lossless: ?*bool) !u64 { + /// Returns `error.InvalidArg` if the value is negative or outside the `u64` + /// range. Use `toU64LowBits` when low-64-bit conversion is intended. + pub fn toU64(self: BigInt) !u64 { + var lossless = false; + const value = try self.toU64LowBits(&lossless); + if (!lossless) return error.InvalidArg; + return value; + } + + /// Converts the JavaScript BigInt to the low 64 bits as a Zig `u64`. + /// + /// If `lossless` is provided, it is set to whether the returned `u64` + /// represents the original BigInt exactly. Out-of-range or negative values + /// still return their low 64 bits, matching N-API BigInt conversion + /// semantics, but set `lossless` to false. + pub fn toU64LowBits(self: BigInt, lossless: ?*bool) !u64 { return self.val.getValueBigintUint64(lossless); } - /// Attempts to convert the JavaScript BigInt to a Zig `i128`. + /// Attempts to convert the JavaScript BigInt exactly to a Zig `i128`. + /// + /// Returns `error.InvalidArg` if the value is outside `[-2^127, 2^127)`. + /// Use `toI128LowBits` when low-128-bit conversion is intended. /// - /// This function reads the BigInt as two 64-bit words and reconstructs it - /// into a Zig `i128`. It handles both positive and negative BigInts. - /// Returns `error.Overflow` if the value is outside the i128 range, and an - /// error if N-API operations fail. + /// Returns an error if N-API operations fail. pub fn toI128(self: BigInt) !i128 { var sign_bit: u1 = 0; var words: [2]u64 = .{ 0, 0 }; - const result = try self.val.getValueBigintWords(&sign_bit, &words); - const lo: u128 = if (result.len > 0) result[0] else 0; - const hi: u128 = if (result.len > 1) result[1] else 0; + // BigInts needing more than two words are always outside the i128 + // range; getValueBigintWords reports them as error.Overflow. + _ = self.val.getValueBigintWords(&sign_bit, &words) catch |err| switch (err) { + error.Overflow => return error.InvalidArg, + else => |e| return e, + }; + const lo: u128 = words[0]; + const hi: u128 = words[1]; const magnitude: u128 = (hi << 64) | lo; const max_positive: u128 = std.math.maxInt(i128); if (sign_bit == 1) { - if (magnitude > max_positive + 1) return error.Overflow; + if (magnitude > max_positive + 1) return error.InvalidArg; if (magnitude == max_positive + 1) return std.math.minInt(i128); return -@as(i128, @intCast(magnitude)); } - if (magnitude > max_positive) return error.Overflow; + if (magnitude > max_positive) return error.InvalidArg; return @intCast(magnitude); } + /// Converts the JavaScript BigInt to the low 128 bits as a Zig `i128`. + /// + /// This returns `BigInt.asIntN(128, b)`, i.e. the low 128 bits + /// of the magnitude reinterpreted as a signed i128. This matches the + /// ECMAScript `BigInt.asIntN` semantics defined at + /// https://tc39.es/ecma262/#sec-bigint.asintn. + /// + /// Returns an error if N-API operations fail. + pub fn toI128LowBits(self: BigInt) !i128 { + var sign_bit: u1 = 0; + // Pre-zeroed: NAPI writes only as many words as the BigInt has; unused + // words stay 0. When the value is 0n NAPI returns word_count == 0, so + // both words[0] and words[1] remain 0 — magnitude correctly becomes 0. + // When the BigInt exceeds 128 bits, getValueBigintWords returns + // error.Overflow but NAPI has still filled the two lower words, so + // swallowing the error gives BigInt.asIntN(128) truncation semantics. + var words: [2]u64 = .{ 0, 0 }; + _ = self.val.getValueBigintWords(&sign_bit, &words) catch |err| switch (err) { + error.Overflow => {}, + else => |e| return e, + }; + const lo: u128 = words[0]; + const hi: u128 = words[1]; + const magnitude: u128 = (hi << 64) | lo; + if (sign_bit == 1) { + // Negative: result = -magnitude interpreted as i128. + // Use wrapping subtraction + bitcast to handle both minInt(i128) + // (magnitude == 2^127, which doesn't fit in i128 as positive) and + // out-of-range values (magnitude > 2^127, truncated to low 128 bits). + // Guard: out-of-range negatives whose low 128 bits are all zero + // (e.g. -2^128n → words [0, 0]) give magnitude == 0. asIntN(128) + // of such values is 0n; return early rather than relying on the + // `0 -% 0 == 0` coincidence below. + if (magnitude == 0) return 0; + return @bitCast(0 -% magnitude); + } + // Positive: bitcast u128 → i128 gives BigInt.asIntN(128) semantics. + // In-range values have magnitude ≤ 2^127-1 so the sign bit is clear; + // out-of-range values have the sign bit set, matching JS asIntN(128). + return @bitCast(magnitude); + } + /// Converts the JavaScript BigInt to a Zig `i64`, panicking on failure. /// /// This is a convenience method for cases where the BigInt is guaranteed - /// to be representable as an `i64` without loss. + /// to be exactly representable as an `i64`. pub fn assertI64(self: BigInt) i64 { - return self.toI64(null) catch @panic("BigInt.assertI64 failed"); + return self.toI64() catch @panic("BigInt.assertI64 failed"); } /// Converts the JavaScript BigInt to a Zig `u64`, panicking on failure. /// /// This is a convenience method for cases where the BigInt is guaranteed - /// to be representable as a `u64` without loss. + /// to be exactly representable as a `u64`. pub fn assertU64(self: BigInt) u64 { - return self.toU64(null) catch @panic("BigInt.assertU64 failed"); + return self.toU64() catch @panic("BigInt.assertU64 failed"); } /// Converts the JavaScript BigInt to a Zig `i128`, panicking on failure. /// /// This is a convenience method for cases where the BigInt is guaranteed - /// to be representable as an `i128`. + /// to be exactly representable as an `i128`. pub fn assertI128(self: BigInt) i128 { return self.toI128() catch @panic("BigInt.assertI128 failed"); } From 0dcc5983dee798889fd4f4bd57ec311f269520c0 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 19 Oct 2026 12:20:09 +0200 Subject: [PATCH 5/5] refactor(js)!: report inexact Number and BigInt conversions as LossyConversion Exact conversions now share one error. BigInt previously reused InvalidArg, which collides with N-API's own invalid-argument status. BREAKING CHANGE: Number.toU32Exact and Number.toSafeInteger return error.LossyConversion instead of error.InvalidUnsignedInteger. Co-Authored-By: Claude Opus 5.5 --- examples/js_dsl/mod.test.ts | 16 ++++++++-------- src/js/bigint.zig | 16 ++++++++-------- src/js/number.zig | 6 +++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index 73cbc99..56ab659 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -67,7 +67,7 @@ describe("primitive types", () => { it("exactU32 rejects values outside the u32 range", () => { for (const value of [-1, 1.5, 2 ** 32, Number.MAX_SAFE_INTEGER, Infinity, -Infinity, NaN]) { - expect(() => mod.exactU32(value), `value ${value}`).toThrow("InvalidUnsignedInteger"); + expect(() => mod.exactU32(value), `value ${value}`).toThrow("LossyConversion"); } }); @@ -79,14 +79,14 @@ describe("primitive types", () => { it("safeInteger rejects values that are not non-negative safe integers", () => { for (const value of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, Infinity, -Infinity, NaN]) { - expect(() => mod.safeInteger(value), `value ${value}`).toThrow("InvalidUnsignedInteger"); + expect(() => mod.safeInteger(value), `value ${value}`).toThrow("LossyConversion"); } }); // 2^32 separates the two: in range for a safe integer, out of range for a u32. it("safeInteger and exactU32 differ exactly at the u32 boundary", () => { expect(mod.safeInteger(2 ** 32)).toEqual(2 ** 32); - expect(() => mod.exactU32(2 ** 32)).toThrow("InvalidUnsignedInteger"); + expect(() => mod.exactU32(2 ** 32)).toThrow("LossyConversion"); }); it("toggleBool", () => { @@ -156,11 +156,11 @@ describe("primitive types", () => { }); it("rejects BigInts outside the i128 range instead of crashing", () => { - expect(() => mod.bigIntToI128String(2n ** 127n)).toThrow(); - expect(() => mod.bigIntToI128String(-(2n ** 127n) - 1n)).toThrow(); - expect(() => mod.bigIntToI128String(2n ** 128n)).toThrow(); - expect(() => mod.bigIntToI128String(2n ** 200n)).toThrow(); - expect(() => mod.bigIntToI128String(-(2n ** 200n))).toThrow(); + expect(() => mod.bigIntToI128String(2n ** 127n)).toThrow("LossyConversion"); + expect(() => mod.bigIntToI128String(-(2n ** 127n) - 1n)).toThrow("LossyConversion"); + expect(() => mod.bigIntToI128String(2n ** 128n)).toThrow("LossyConversion"); + expect(() => mod.bigIntToI128String(2n ** 200n)).toThrow("LossyConversion"); + expect(() => mod.bigIntToI128String(-(2n ** 200n))).toThrow("LossyConversion"); }); }); diff --git a/src/js/bigint.zig b/src/js/bigint.zig index f6cdd9b..acbb908 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -16,12 +16,12 @@ pub const BigInt = struct { /// Attempts to convert the JavaScript BigInt exactly to a Zig `i64`. /// - /// Returns `error.InvalidArg` if the value is outside the `i64` range. Use + /// Returns `error.LossyConversion` if the value is outside the `i64` range. Use /// `toI64LowBits` when low-64-bit conversion is intended. pub fn toI64(self: BigInt) !i64 { var lossless = false; const value = try self.toI64LowBits(&lossless); - if (!lossless) return error.InvalidArg; + if (!lossless) return error.LossyConversion; return value; } @@ -37,12 +37,12 @@ pub const BigInt = struct { /// Attempts to convert the JavaScript BigInt exactly to a Zig `u64`. /// - /// Returns `error.InvalidArg` if the value is negative or outside the `u64` + /// Returns `error.LossyConversion` if the value is negative or outside the `u64` /// range. Use `toU64LowBits` when low-64-bit conversion is intended. pub fn toU64(self: BigInt) !u64 { var lossless = false; const value = try self.toU64LowBits(&lossless); - if (!lossless) return error.InvalidArg; + if (!lossless) return error.LossyConversion; return value; } @@ -58,7 +58,7 @@ pub const BigInt = struct { /// Attempts to convert the JavaScript BigInt exactly to a Zig `i128`. /// - /// Returns `error.InvalidArg` if the value is outside `[-2^127, 2^127)`. + /// Returns `error.LossyConversion` if the value is outside `[-2^127, 2^127)`. /// Use `toI128LowBits` when low-128-bit conversion is intended. /// /// Returns an error if N-API operations fail. @@ -68,7 +68,7 @@ pub const BigInt = struct { // BigInts needing more than two words are always outside the i128 // range; getValueBigintWords reports them as error.Overflow. _ = self.val.getValueBigintWords(&sign_bit, &words) catch |err| switch (err) { - error.Overflow => return error.InvalidArg, + error.Overflow => return error.LossyConversion, else => |e| return e, }; const lo: u128 = words[0]; @@ -76,11 +76,11 @@ pub const BigInt = struct { const magnitude: u128 = (hi << 64) | lo; const max_positive: u128 = std.math.maxInt(i128); if (sign_bit == 1) { - if (magnitude > max_positive + 1) return error.InvalidArg; + if (magnitude > max_positive + 1) return error.LossyConversion; if (magnitude == max_positive + 1) return std.math.minInt(i128); return -@as(i128, @intCast(magnitude)); } - if (magnitude > max_positive) return error.InvalidArg; + if (magnitude > max_positive) return error.LossyConversion; return @intCast(magnitude); } diff --git a/src/js/number.zig b/src/js/number.zig index e38feeb..9a4d358 100644 --- a/src/js/number.zig +++ b/src/js/number.zig @@ -65,7 +65,7 @@ pub const Number = struct { value > max or @trunc(value) != value) { - return error.InvalidUnsignedInteger; + return error.LossyConversion; } return @intFromFloat(value); } @@ -74,7 +74,7 @@ pub const Number = struct { /// /// Exact across the whole `u32` range. /// - /// Returns `error.InvalidUnsignedInteger` if the number is negative, + /// Returns `error.LossyConversion` if the number is negative, /// fractional, non-finite, or greater than `std.math.maxInt(u32)`. pub fn toU32Exact(self: Number) !u32 { return self.toUnsignedExact(u32, std.math.maxInt(u32)); @@ -83,7 +83,7 @@ pub const Number = struct { /// Attempts to convert the JavaScript number to a non-negative JS safe /// integer, returned as a `u64`. /// - /// Returns `error.InvalidUnsignedInteger` if the number is negative, + /// Returns `error.LossyConversion` if the number is negative, /// fractional, non-finite, or greater than `Number.MAX_SAFE_INTEGER` /// (2^53 - 1). ///