From 840096533764e7343936aeb04efa794aa1d06e80 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 19 Oct 2026 12:09:47 +0200 Subject: [PATCH 1/4] 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 a118382059bf53f9c56c54b090c572ee04d41fc4 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 19 Oct 2026 12:09:47 +0200 Subject: [PATCH 2/4] 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 67b6d8051f5d98c38455cb96c24283d00e4a36b7 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 19 Oct 2026 12:09:52 +0200 Subject: [PATCH 3/4] 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 226ade0921fa8c430c1573c1f8f8c4a04299aaad Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 6 Nov 2026 12:41:20 +0100 Subject: [PATCH 4/4] feat(js): add exact BigInt i64/u64 conversions and toI128LowBits BigInt.toI64Exact and BigInt.toU64Exact return error.Overflow when the value does not fit, instead of the low 64 bits toI64/toU64 return. BigInt.toI128LowBits returns BigInt.asIntN(128) semantics. Existing methods are unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- examples/js_dsl/mod.test.ts | 5 +++++ examples/js_dsl/mod.zig | 3 +-- src/js/bigint.zig | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3de47b1..874c124 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()`, `toI64Exact()`, `toU64Exact()`, `toI128()`, `toI128LowBits()`, `from(anytype)`, `fromWords()` | | `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.test.ts b/examples/js_dsl/mod.test.ts index 63bd513..fda2f2e 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -113,6 +113,11 @@ describe("primitive types", () => { expect(mod.doubleBigInt(50n)).toEqual(100n); }); + it("doubleBigInt rejects BigInts outside the i64 range", () => { + expect(() => mod.doubleBigInt(2n ** 63n)).toThrow("Overflow"); + expect(() => mod.doubleBigInt(-(2n ** 63n) - 1n)).toThrow("Overflow"); + }); + it("doubleBigInt rejects non-bigint arguments at the JS boundary", () => { expectTypeErrorWithMessage(() => mod.doubleBigInt(50), "Argument 1 must be a bigint"); expectTypeErrorWithMessage(() => mod.doubleBigInt("50"), "Argument 1 must be a bigint"); diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index 33dd0a9..341b2e1 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.toI64Exact(); return BigInt.from(val * 2); } diff --git a/src/js/bigint.zig b/src/js/bigint.zig index d3e3fdc..ed19ad4 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -34,6 +34,28 @@ pub const BigInt = struct { return self.val.getValueBigintUint64(lossless); } + /// Converts the JavaScript BigInt exactly to a Zig `i64`. + /// + /// Returns `error.Overflow` if the value is outside the `i64` range, + /// instead of the low 64 bits `toI64` returns. + pub fn toI64Exact(self: BigInt) !i64 { + var lossless = false; + const value = try self.toI64(&lossless); + if (!lossless) return error.Overflow; + return value; + } + + /// Converts the JavaScript BigInt exactly to a Zig `u64`. + /// + /// Returns `error.Overflow` if the value is negative or outside the `u64` + /// range, instead of the low 64 bits `toU64` returns. + pub fn toU64Exact(self: BigInt) !u64 { + var lossless = false; + const value = try self.toU64(&lossless); + if (!lossless) return error.Overflow; + return value; + } + /// Attempts to convert the JavaScript BigInt to a Zig `i128`. /// /// This function reads the BigInt as two 64-bit words and reconstructs it @@ -57,6 +79,24 @@ pub const BigInt = struct { return @intCast(magnitude); } + /// Converts the JavaScript BigInt to the low 128 bits as a Zig `i128`. + /// + /// Returns `BigInt.asIntN(128, b)` + /// (https://tc39.es/ecma262/#sec-bigint.asintn), so out-of-range values + /// wrap instead of failing. Use `toI128` for an exact conversion. + pub fn toI128LowBits(self: BigInt) !i128 { + var sign_bit: u1 = 0; + var words: [2]u64 = .{ 0, 0 }; + // On overflow N-API still fills the two low words and the sign. + _ = self.val.getValueBigintWords(&sign_bit, &words) catch |err| switch (err) { + error.Overflow => {}, + else => |e| return e, + }; + const magnitude: u128 = (@as(u128, words[1]) << 64) | words[0]; + if (sign_bit == 1) return @bitCast(0 -% magnitude); + 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