From d228c4d0db71e3a08292c1a5d33599023f40f50b Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 28 Sep 2026 14:48:06 +0200 Subject: [PATCH 1/3] refactor!: rename Number.toU64Exact to toSafeInteger toU64Exact never returns a value above 2^53-1, so the name promised a range it could not deliver -- especially sitting next to toU32Exact, which is exact across its entire nominal range. The 2^53 cap itself is correct and is kept. A JS number is an f64, and @floatFromInt(std.math.maxInt(u64)) rounds *up* to 2^64, which would admit a value @intFromFloat cannot represent. Both conversions now share a private toUnsignedExact(T) that derives the bound with @min(maxInt(T), maxInt(u53)), so the clamp is total instead of a constant repeated per overload. Also adds DSL coverage for toU32Exact, which has had none since it landed, including a case pinning where the two conversions diverge. BREAKING CHANGE: Number.toU64Exact is renamed to Number.toSafeInteger, with no deprecation alias. Behaviour is unchanged -- the same inputs are accepted and the same error.InvalidUnsignedInteger is returned -- so migrating is a rename at the call site. lodestar-z is the only consumer: 30 call sites, 29 in bindings/napi/BeaconStateView.zig and 1 in bindings/napi/BeaconConfig.zig. Co-Authored-By: Claude Opus 5 (1M context) --- examples/js_dsl/mod.test.ts | 30 +++++++++++++++++++----- examples/js_dsl/mod.zig | 11 ++++++--- src/js/number.zig | 46 +++++++++++++++++++++---------------- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index b348ede..73cbc99 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -59,18 +59,36 @@ describe("primitive types", () => { expectTypeErrorWithMessage(() => mod.doubleNumber("21"), "Argument 1 must be a number"); }); - it("exactU64 accepts safe unsigned integers", () => { - expect(mod.exactU64(0)).toEqual(0); - expect(mod.exactU64(2 ** 32)).toEqual(2 ** 32); - expect(mod.exactU64(Number.MAX_SAFE_INTEGER)).toEqual(Number.MAX_SAFE_INTEGER); + it("exactU32 accepts every value in the u32 range", () => { + expect(mod.exactU32(0)).toEqual(0); + expect(mod.exactU32(2 ** 31)).toEqual(2 ** 31); + expect(mod.exactU32(2 ** 32 - 1)).toEqual(2 ** 32 - 1); }); - it("exactU64 rejects values that are not exact u64 integers", () => { + 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"); + } + }); + + it("safeInteger accepts every non-negative JS safe integer", () => { + expect(mod.safeInteger(0)).toEqual(0); + expect(mod.safeInteger(2 ** 32)).toEqual(2 ** 32); + expect(mod.safeInteger(Number.MAX_SAFE_INTEGER)).toEqual(Number.MAX_SAFE_INTEGER); + }); + + 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.exactU64(value), `value ${value}`).toThrow("InvalidUnsignedInteger"); + expect(() => mod.safeInteger(value), `value ${value}`).toThrow("InvalidUnsignedInteger"); } }); + // 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"); + }); + it("toggleBool", () => { expect(mod.toggleBool(true)).toBe(false); expect(mod.toggleBool(false)).toBe(true); diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index 23e928c..f01b73e 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -79,9 +79,14 @@ pub fn largeUnsignedBoundary() Number { return Number.from(@as(u64, std.math.maxInt(i64)) + 1); } -/// Return a number validated as an exact u64 by `toU64Exact`. -pub fn exactU64(n: Number) !Number { - return Number.from(try n.toU64Exact()); +/// Return a number validated as an exact u32 by `toU32Exact`. +pub fn exactU32(n: Number) !Number { + return Number.from(try n.toU32Exact()); +} + +/// Return a number validated as a JS safe integer by `toSafeInteger`. +pub fn safeInteger(n: Number) !Number { + return Number.from(try n.toSafeInteger()); } /// Negate a boolean. diff --git a/src/js/number.zig b/src/js/number.zig index eeb08dc..970dde7 100644 --- a/src/js/number.zig +++ b/src/js/number.zig @@ -44,12 +44,15 @@ pub const Number = struct { return self.val.getValueUint32(); } - /// Attempts to convert the JavaScript number to a `u32` without coercion. - /// - /// Returns `error.InvalidUnsignedInteger` if the number is negative, - /// fractional, non-finite, or greater than `std.math.maxInt(u32)`. - pub fn toU32Exact(self: Number) !u32 { - const max: f64 = @floatFromInt(std.math.maxInt(u32)); + /// Attempts to convert the JavaScript number to an unsigned `T` without + /// coercion, rejecting anything a JS number cannot hold exactly. + /// + /// The upper bound is clamped to `Number.MAX_SAFE_INTEGER` (2^53 - 1): a JS + /// number is an `f64`, so no larger integer round-trips, and + /// `@floatFromInt(std.math.maxInt(u64))` rounds *up* to 2^64, which would + /// admit a value `@intFromFloat` cannot represent. + fn toUnsignedExact(self: Number, comptime T: type) !T { + const max: f64 = @floatFromInt(@min(std.math.maxInt(T), std.math.maxInt(u53))); const value = try self.toF64(); if (!std.math.isFinite(value) or value < 0 or @@ -61,25 +64,28 @@ pub const Number = struct { return @intFromFloat(value); } - /// Attempts to convert the JavaScript number to a `u64` without coercion. + /// Attempts to convert the JavaScript number to a `u32` without coercion. + /// + /// Exact across the whole `u32` range. + /// + /// Returns `error.InvalidUnsignedInteger` 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); + } + + /// 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, /// fractional, non-finite, or greater than `Number.MAX_SAFE_INTEGER` /// (2^53 - 1). /// - /// Larger values cannot be represented exactly by a JS number; - /// use `BigInt.toU64` for the full `u64` range. - pub fn toU64Exact(self: Number) !u64 { - const max: f64 = @floatFromInt(std.math.maxInt(u53)); - const value = try self.toF64(); - if (!std.math.isFinite(value) or - value < 0 or - value > max or - @trunc(value) != value) - { - return error.InvalidUnsignedInteger; - } - return @intFromFloat(value); + /// This is *not* a full `u64` conversion, and no such conversion exists for + /// a JS number: values above 2^53 - 1 are already imprecise by the time + /// they reach Zig. Use `BigInt.toU64` for the full `u64` range. + pub fn toSafeInteger(self: Number) !u64 { + return self.toUnsignedExact(u64); } /// Attempts to convert the JavaScript number to a Zig `f64`. From f933e62ce4748aee3c886a392cc8c8fd48237098 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 28 Sep 2026 15:56:49 +0200 Subject: [PATCH 2/3] refactor: pass the exact bound explicitly instead of deriving it Review feedback: deriving the bound inside the helper with @min(maxInt(T), maxInt(u53)) meant toSafeInteger asked for a u64 only to have it silently clamped, so a reader had to evaluate the @min to learn what the function actually accepts. Each caller now states its own bound, and a comptime assert keeps the property the @min was providing implicitly: a bound above maxInt(u53) is a compile error rather than a value @intFromFloat cannot represent. Behaviour is unchanged; both bounds are already pinned by the DSL tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/js/number.zig | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/js/number.zig b/src/js/number.zig index 970dde7..0e0e457 100644 --- a/src/js/number.zig +++ b/src/js/number.zig @@ -45,14 +45,16 @@ pub const Number = struct { } /// Attempts to convert the JavaScript number to an unsigned `T` without - /// coercion, rejecting anything a JS number cannot hold exactly. - /// - /// The upper bound is clamped to `Number.MAX_SAFE_INTEGER` (2^53 - 1): a JS - /// number is an `f64`, so no larger integer round-trips, and - /// `@floatFromInt(std.math.maxInt(u64))` rounds *up* to 2^64, which would - /// admit a value `@intFromFloat` cannot represent. - fn toUnsignedExact(self: Number, comptime T: type) !T { - const max: f64 = @floatFromInt(@min(std.math.maxInt(T), std.math.maxInt(u53))); + /// coercion, rejecting anything outside `[0, max_int]` and anything a JS + /// number cannot hold exactly. + /// + /// `max_int` may not exceed `Number.MAX_SAFE_INTEGER` (2^53 - 1). A JS + /// number is an `f64`, so nothing larger round-trips, and + /// `@floatFromInt(std.math.maxInt(u64))` rounds *up* to 2^64 — a bound that + /// would admit a value `@intFromFloat` cannot represent. + fn toUnsignedExact(self: Number, comptime T: type, comptime max_int: comptime_int) !T { + comptime std.debug.assert(max_int <= std.math.maxInt(u53)); + const max: f64 = @floatFromInt(max_int); const value = try self.toF64(); if (!std.math.isFinite(value) or value < 0 or @@ -71,7 +73,7 @@ pub const Number = struct { /// Returns `error.InvalidUnsignedInteger` 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); + return self.toUnsignedExact(u32, std.math.maxInt(u32)); } /// Attempts to convert the JavaScript number to a non-negative JS safe @@ -85,7 +87,7 @@ pub const Number = struct { /// a JS number: values above 2^53 - 1 are already imprecise by the time /// they reach Zig. Use `BigInt.toU64` for the full `u64` range. pub fn toSafeInteger(self: Number) !u64 { - return self.toUnsignedExact(u64); + return self.toUnsignedExact(u64, std.math.maxInt(u53)); } /// Attempts to convert the JavaScript number to a Zig `f64`. From b73b77fdd5b76a136dd45955677ea6fe4b13074e Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Mon, 28 Sep 2026 17:50:18 +0200 Subject: [PATCH 3/3] Update src/js/number.zig Co-authored-by: bing --- src/js/number.zig | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/js/number.zig b/src/js/number.zig index 0e0e457..aca3b0b 100644 --- a/src/js/number.zig +++ b/src/js/number.zig @@ -48,10 +48,9 @@ pub const Number = struct { /// coercion, rejecting anything outside `[0, max_int]` and anything a JS /// number cannot hold exactly. /// - /// `max_int` may not exceed `Number.MAX_SAFE_INTEGER` (2^53 - 1). A JS - /// number is an `f64`, so nothing larger round-trips, and - /// `@floatFromInt(std.math.maxInt(u64))` rounds *up* to 2^64 — a bound that - /// would admit a value `@intFromFloat` cannot represent. + /// The largest representable unsigned integer in zapi is + /// bounded by `self`'s bound which is + /// `Number.MAX_SAFE_INTEGER` (2^53 - 1), a u53 in Zig. fn toUnsignedExact(self: Number, comptime T: type, comptime max_int: comptime_int) !T { comptime std.debug.assert(max_int <= std.math.maxInt(u53)); const max: f64 = @floatFromInt(max_int);