Skip to content

refactor!: rename Number.toU64Exact to toSafeInteger - #81

Open
nazarhussain wants to merge 3 commits into
mainfrom
nh/number-safe-integer
Open

nazarhussain wants to merge 3 commits into
mainfrom
nh/number-safe-integer

Conversation

@nazarhussain

@nazarhussain nazarhussain commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #80 / v4.1.0.

toU64Exact never 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 because toU32Exact sits 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:

u53max as f64 = 9007199254740991       exact round-trip = true
u64max as f64 = 18446744073709552000   == 2^64, rounds_up = true

@floatFromInt(std.math.maxInt(u64)) rounds up to exactly 2^64, so a naive full-range value > max check would admit 2^64 itself and hand it to @intFromFloat, which cannot represent it. So the behaviour stays; only the name changes.

What changed

  • New private 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 a comptime assert makes a bound above maxInt(u53) a compile error rather than one @intFromFloat cannot represent. (Updated from an earlier @min-derived bound after review — see the thread on src/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 exact u64 conversion exists for a JS number and points at BigInt.toU64.
  • Adds DSL coverage for toU32Exact, which has had none since it landed in feat(js): add exact u32 conversion #71, plus a case pinning 2**32 as the exact point where the two conversions diverge.

Breaking change

Renamed with no deprecation alias, and committed as refactor!: with a BREAKING 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.InvalidUnsignedInteger is returned. lodestar-z is the only consumer, with 30 call sites:

File Sites
bindings/napi/BeaconStateView.zig 29
bindings/napi/BeaconConfig.zig 1

chainConfigU64 in BeaconConfig.zig already wraps the conversion to map +Infinity to maxInt(u64) for disabled fork epochs; that wrapper is unaffected.

🤖 Generated with Claude Code

@nazarhussain nazarhussain changed the title refactor: rename Number.toU64Exact to toSafeInteger refactor!: rename Number.toU64Exact to toSafeInteger Sep 28, 2026
Comment thread src/js/number.zig Outdated
/// 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. why don't we just call this toU53Exact(self: Number) !u53? u53 widens to u64, so that is clearer, we can still keep doc comments on why this is u53

  2. Keep toSafeInteger, but skip the u64 and just use

        const max: f64 = @floatFromInt(std.math.maxInt(u53));

later?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted the option 2. That feels semantically correct for the DSL.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not both? u53 is what the underlying can actually produce. Easy to expose as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
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>
Comment thread src/js/number.zig Outdated
Co-authored-by: bing <spiralladder@fastmail.com>
Comment thread src/js/number.zig
Comment on lines +54 to +55
fn toUnsignedExact(self: Number, comptime T: type, comptime max_int: comptime_int) !T {
comptime std.debug.assert(max_int <= std.math.maxInt(u53));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
    }
    ...
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Awaiting Author

Development

Successfully merging this pull request may close these issues.

3 participants