refactor!: rename Number.toU64Exact to toSafeInteger - #81
nazarhussain wants to merge 3 commits into
Conversation
8fa9cb7 to
b8e72d4
Compare
| /// 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); |
There was a problem hiding this comment.
this is a bit strange, we call toUnsignedExact(u64) just to do
const max: f64 = @floatFromInt(@min(std.math.maxInt(T), std.math.maxInt(u53)));
later
I feel like we can do either one of these:
-
why don't we just call this
toU53Exact(self: Number) !u53?u53widens tou64, so that is clearer, we can still keep doc comments on why this is u53 -
Keep
toSafeInteger, but skip theu64and just use
const max: f64 = @floatFromInt(std.math.maxInt(u53));
later?
There was a problem hiding this comment.
I opted the option 2. That feels semantically correct for the DSL.
There was a problem hiding this comment.
why not both? u53 is what the underlying can actually produce. Easy to expose as well.
There was a problem hiding this comment.
It's based on the usage and semantics of the DSL. I feel js.Number.toSafeInteger() is good interface for usage in JS, no need to expose extra interface which also not feel right for JS presepective.
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) <noreply@anthropic.com>
b8e72d4 to
d228c4d
Compare
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) <noreply@anthropic.com>
Co-authored-by: bing <spiralladder@fastmail.com>
| fn toUnsignedExact(self: Number, comptime T: type, comptime max_int: comptime_int) !T { | ||
| comptime std.debug.assert(max_int <= std.math.maxInt(u53)); |
There was a problem hiding this comment.
this is a little strange to me?
I'd expect something like:
/// Attempts to convert a Number to unsigned `T`
/// Note: Numbers larger than MAX_SAFE_INTEGER will throw, even if fit within `T`.
fn toUint(self: Number, comptime T: type) !T {
const max: f64 = if (@typeInfo(T).int.bits >= 53) blk: {
break :blk @floatFromInt(std.math.maxInt(u53));
} else {
break :blk @floatFromInt(T);
}
...
}There was a problem hiding this comment.
Deriving the bound is actually what started as @min(maxInt(T), maxInt(u53)) as same thing to your suggestion if (bits >= 53). That was raised earlier and @bing asked for a u64 only to have toSafeInteger silently clamped.
Passing the bound explicitly is what resolved that, and the comptime assert keeps the guarantee the @min was making implicitly.
Follow-up to #80 / v4.1.0.
toU64Exactnever returns a value above 2^53−1 — everything from 2^53 to 2^64−1 throws. The doc comment said so, but the name promised otherwise, and it promised it specifically becausetoU32Exactsits right above it and is exact across its entire nominal range. Two parallel names, two different contracts.The 2^53 cap is correct and is kept
It isn't a shortcut — it's the only way to write this against an
f64:@floatFromInt(std.math.maxInt(u64))rounds up to exactly 2^64, so a naive full-rangevalue > maxcheck would admit 2^64 itself and hand it to@intFromFloat, which cannot represent it. So the behaviour stays; only the name changes.What changed
toUnsignedExact(comptime T, comptime max_int), shared by both public conversions — the ten-line body is no longer duplicated. Each caller states its own bound, and acomptimeassert makes a bound abovemaxInt(u53)a compile error rather than one@intFromFloatcannot represent. (Updated from an earlier@min-derived bound after review — see the thread onsrc/js/number.zig.)toU32Exact— behaviour unchanged, now a one-liner over the helper.toU64Exact→toSafeInteger() !u64, with a doc comment that says plainly that no exactu64conversion exists for a JS number and points atBigInt.toU64.toU32Exact, which has had none since it landed in feat(js): add exact u32 conversion #71, plus a case pinning2**32as the exact point where the two conversions diverge.Breaking change
Renamed with no deprecation alias, and committed as
refactor!:with aBREAKING CHANGE:footer so release-please cuts a major. Keeping a misleadingly-named alias for a whole major cycle would defeat the point of the change.Migration is a rename at the call site — behaviour is identical, the same inputs are accepted and the same
error.InvalidUnsignedIntegeris returned. lodestar-z is the only consumer, with 30 call sites:bindings/napi/BeaconStateView.zigbindings/napi/BeaconConfig.zigchainConfigU64inBeaconConfig.zigalready wraps the conversion to map+InfinitytomaxInt(u64)for disabled fork epochs; that wrapper is unaffected.🤖 Generated with Claude Code