Passing 64-bit Integers with BigInt
This page answers one task: a WebAssembly function takes or returns 64-bit integers — ids, timestamps in nanoseconds, file offsets, hashes — and JavaScript must handle them without silently losing precision or mixing up signed and unsigned values.
Prerequisites
- [ ] A module with
i64parameters or results (Rustu64/i64, Cint64_t/uint64_t). - [ ] A browser or runtime with WebAssembly BigInt integration (all current browsers, Node 15+).
Why 64-bit integers are special
JavaScript’s Number is a 64-bit float, which represents integers exactly only up to 2⁵³ − 1 (Number.MAX_SAFE_INTEGER, about 9 × 10¹⁵). WebAssembly’s
i64 holds the full 64-bit range. Converting one to the other silently rounds anything larger: the id 9007199254740993 becomes 9007199254740992,
and two distinct ids compare equal. Nothing throws; the bug surfaces later as a wrong record, a duplicate key or a hash mismatch.
The JS-API therefore maps i64 to BigInt, JavaScript’s arbitrary-precision integer type. A Wasm function returning i64 returns a BigInt
(123n); a function taking i64 requires a BigInt and throws a TypeError if given a Number. That strictness is deliberate: it prevents silent
precision loss at the boundary. Older engines without BigInt integration could not pass i64 at all, which is why some toolchains still split 64-bit
values into two 32-bit halves (-sWASM_BIGINT=0 in Emscripten, now off by default).
Step 1 — receive BigInt from raw exports
(func (export "file_size") (param $handle i32) (result i64) …)
const size = instance.exports.file_size(h); // 5368709120n (5 GiB)
typeof size; // "bigint"
instance.exports.seek(h, 4096n); // passing i64: must be a BigInt
instance.exports.seek(h, 4096); // TypeError: Cannot convert 4096 to a BigInt
Pass values with the n suffix or BigInt(x). Convert results to Number only when you know they are in the safe range, and check rather than assume:
function toSafeNumber(b) {
if (b > BigInt(Number.MAX_SAFE_INTEGER) || b < BigInt(Number.MIN_SAFE_INTEGER)) {
throw new RangeError(`${b} is outside the safe integer range`);
}
return Number(b);
}
Step 2 — signed versus unsigned
WebAssembly has one i64 type; signedness is in the operations, not the type. The JS-API always converts i64 as signed: a Rust u64 of
18446744073709551615 (all bits set) arrives in JavaScript as -1n from a raw export. Convert explicitly:
const asU64 = (b) => BigInt.asUintN(64, b); // -1n → 18446744073709551615n
const asI64 = (b) => BigInt.asIntN(64, b); // reverse, before passing a large u64 back in
BigInt.asUintN and asIntN reinterpret the bits without arithmetic, exactly like a cast in Rust or C. Hashes, bitmasks and unsigned ids above 2⁶³ need
this; most other values — sizes, timestamps, counts — never reach the sign bit, so the issue stays hidden until a large value appears.
Step 3 — let wasm-bindgen do the conversion
wasm-bindgen handles signedness for you: u64 parameters and results are converted as unsigned, i64 as signed, both as bigint in TypeScript:
#[wasm_bindgen]
pub fn hash64(data: &[u8]) -> u64 { xxhash_rust::xxh3::xxh3_64(data) }
#[wasm_bindgen]
pub fn from_unix_nanos(nanos: i64) -> String { /* … */ }
hash64(bytes); // 15523640528713421934n — unsigned, as in Rust
from_unix_nanos(1_700_000_000_000_000_000n);
u128 and i128 are also supported in recent versions, converted to bigint. Collections of 64-bit values are best passed as BigUint64Array or
BigInt64Array, which wasm-bindgen accepts for &[u64] and &[i64] and which view linear memory directly with no per-element conversion.
Step 4 — decide where precision is needed
Not every 64-bit value needs to stay a BigInt. Sizes in bytes, millisecond timestamps and counts are within 2⁵³ in practice; converting them to Number
at the wrapper makes them pleasant to use in arithmetic and UI code, provided the conversion checks the range. Identifiers, hashes, nanosecond
timestamps and bitmasks must stay exact: keep them as BigInt, or convert to strings for display and for use as Map keys and in JSON. Make the choice
per field in the wrapper, and type it in TypeScript (bigint versus number) so callers cannot mix them up — TypeScript rejects bigint + number, which
catches many mistakes at compile time.
A practical approach is to define two small wrapper types in the TypeScript layer — an Id alias for bigint and plain number for everything
range-checked — and convert at exactly one place: the function that wraps each export. Application code then never decides; it receives values already in the
right representation, with the conversion and its range check written once and tested once.
Step 5 — handle JSON and serialisation
JSON.stringify throws on BigInt values (TypeError: Do not know how to serialize a BigInt), and JSON numbers lose precision when parsed. Encode 64-bit
values as strings in JSON, and convert back with BigInt(str). With serde-wasm-bindgen, enable serialize_large_number_types_as_bigints(true) to get
bigint in JavaScript objects, as described in
serializing data with serde-wasm-bindgen;
the default converts to Number and errors if the value is not exactly representable. For persisted data, serialising as decimal strings is the most
portable choice.
Where 64-bit values come from without being asked for
Some 64-bit values arrive by surprise. WASI functions use u64 for file sizes, offsets and timestamps, so a module that calls clock_time_get or
fd_seek produces 64-bit results that bindings then expose as BigInt. Rust’s usize is 32 bits on wasm32 but 64 bits on wasm64, so code moving to
Memory64 sees pointer-sized values change type at the boundary. Hash functions such as xxHash and FNV-64 return u64 naturally. And C code using
long long or time_t (64-bit in Emscripten) produces i64 exports. When a function that used to return a Number starts returning a BigInt after a
dependency or toolchain update, the cause is usually one of these, and the fix belongs in the wrapper: decide per value whether it should stay a BigInt
or be range-checked into a Number. A unit test that calls each export and asserts the typeof of its result catches such changes before they reach
callers that do arithmetic with mixed types.
Displaying and formatting 64-bit values
BigInts render fine with String(x) and template literals, but common formatting tools need care. Intl.NumberFormat accepts BigInt and formats it with
grouping separators — new Intl.NumberFormat("en").format(5368709120n) gives "5,368,709,120" — and toLocaleString() works on BigInt directly. For
byte sizes, divide in BigInt first (size / 1024n), then convert the much smaller result to Number for fractional formatting. Hashes are usually shown
in hexadecimal: x.toString(16).padStart(16, "0") produces the fixed-width form that matches what Rust prints with {:016x}, which makes values easy to
compare across logs from both sides. Timestamps in nanoseconds are best converted to milliseconds with Number(nanos / 1_000_000n) before passing them to
Date, which only understands millisecond Numbers. Doing the division in BigInt keeps full precision until the value is small enough to be safe.
Centralising these conversions in a few helper functions keeps display code clean and makes the precision rules visible in one place.
Expected output
hash64(bytes) returns the same 64-bit value as the Rust native test, as an unsigned bigint; an id of 9007199254740993n round-trips through the module
unchanged; and toSafeNumber throws instead of silently rounding when a size exceeds 2⁵³ − 1.
Gotchas
- Passing a Number where
i64is expected. Raw exports throwTypeError. Pass a BigInt. - Unsigned values arriving negative. Raw exports convert as signed. Use
BigInt.asUintN(64, x). Number(big)rounding silently. Range-check before converting.JSON.stringifythrowing on BigInt. Convert to strings first, or supply a replacer.- Mixing
bigintandnumberin arithmetic. It throws. Convert explicitly on one side.
Performance note
Passing a BigInt across the boundary cost about 15 ns more per call than an i32 in Chrome, because BigInt values are heap objects. For bulk data, a
BigUint64Array view over linear memory read 1 million values in 4 ms; returning them one call at a time took 31 ms.
Frequently Asked Questions
Can I avoid BigInt by splitting into two 32-bit halves? Yes, and some older APIs do, but BigInt integration is universal now and simpler. Splitting remains useful for very hot paths.
Are BigInt operations slow? Slower than Number arithmetic, especially for allocation-heavy loops, but fine for ids and occasional values.
What about Emscripten?
-sWASM_BIGINT is on by default in current versions, so int64_t crosses as BigInt; older code may still use legalised pairs.
Can I use BigInt as a Map key?
Yes — BigInts compare by value, so map.get(5n) finds a key set with 5n.
Does WebAssembly need BigInt for u32 values above 2³¹?
No. i32 results are converted as signed Numbers, so a u32 above 2³¹ arrives negative; fix it with x >>> 0. wasm-bindgen does this for u32.
Can typed arrays of BigInt be transferred to workers?
Yes — BigInt64Array and BigUint64Array are ordinary typed arrays whose buffers can be transferred or shared.
Related
- Passing optional values and nulls — optional 64-bit values.
- Choosing between JSON and binary serialization — 64-bit values in payloads.
- Reading Wasm linear memory with typed arrays — BigInt64Array views.
- Memory64 and large heaps — where pointers become 64-bit.