Using Non-Trapping Float-to-Int Conversions
This page answers one question: a WebAssembly module traps with “float unrepresentable in integer range” or “integer overflow” on some inputs, and the code only converts a floating-point number to an integer. You want to understand WebAssembly’s two families of conversion instructions, which one your compiler emits, and how to make conversions behave the way your program expects.
Prerequisites
- [ ] A module that converts floats to integers (casts in Rust, C, C++ or another language).
- [ ]
wasm-tools printorwasm2watto inspect instructions. - [ ] Inputs that include NaN, infinities or very large values.
Two families of conversion
The original WebAssembly instruction set (the MVP) has i32.trunc_f32_s, i32.trunc_f64_u, i64.trunc_f64_s and so on: they truncate toward zero and trap
if the input is NaN or outside the destination type’s range. That matches how some CPUs behave, but it means a single bad value — a NaN from a division by zero,
a huge coordinate — aborts the whole module.
The non-trapping float-to-int proposal (part of the standard, supported everywhere current) added saturating variants: i32.trunc_sat_f32_s and friends. They
never trap: NaN converts to 0, values above the maximum convert to the maximum, values below the minimum convert to the minimum. Many languages define their
casts this way, so the saturating instructions let compilers implement those semantics with one instruction instead of a sequence of comparisons.
Step 1 — know what your language’s cast means
Rust’s as casts from float to integer have been saturating since Rust 1.45: NaN becomes 0 and out-of-range values clamp. On wasm32 with the
nontrapping-fptoint target feature (enabled by default in current toolchains), x as i32 compiles to a single trunc_sat instruction. Without the feature,
the compiler emits comparisons and branches around a trapping trunc to achieve the same semantics. Rust also offers f64::to_int_unchecked, which is
unsafe and undefined for out-of-range values.
In C and C++, converting an out-of-range or NaN float to an integer is undefined behaviour. Clang targeting Wasm may emit a trapping trunc (so out-of-range
inputs trap) or, with options and depending on version, a saturating instruction. Code that relied on a native platform’s behaviour (x86 returns a “sentinel”
minimum value) behaves differently in Wasm.
Step 2 — inspect the generated instructions
wasm-tools print app.wasm | grep -E "trunc(_sat)?_f(32|64)" | sort | uniq -c
# 14 i32.trunc_sat_f64_s
# 3 i32.trunc_f64_s
Trapping trunc instructions in a module mean some conversion can trap. Find which functions contain them (the surrounding func names) and decide whether that
is intended.
Step 3 — make the semantics explicit in C and C++
Because the conversion is undefined behaviour for out-of-range values, write the intended behaviour:
#include <math.h>
#include <stdint.h>
static inline int32_t to_i32_saturating(double x) {
if (isnan(x)) return 0;
if (x >= 2147483647.0) return INT32_MAX;
if (x <= -2147483648.0) return INT32_MIN;
return (int32_t)x; // now in range: defined, compiles to a truncation
}
Compilers with the non-trapping feature enabled may turn this pattern into a single trunc_sat. Clang also provides __builtin_wasm_trunc_saturate_s_i32_f64
and related builtins for direct access.
Step 4 — decide between trapping and saturating
Saturation is not always the right answer. A NaN turning silently into 0 can hide a bug that produces wrong output; a trap at least fails loudly. For coordinates, colours and indices derived from untrusted input, saturate (and often clamp further to a valid range). For internal computations where NaN indicates a logic error, check explicitly and report an error rather than either trapping or silently converting.
Step 5 — test edge cases
Add tests that feed NaN, positive and negative infinity, values just beyond the integer range, and the boundary values themselves into every conversion that handles external data. Run them against the Wasm build (natively, behaviour may differ): a conversion that traps only in Wasm is a classic source of “works on desktop, crashes in the browser”.
Unsigned conversions and 64-bit targets
Unsigned conversions (_u variants) saturate negative inputs to 0. Conversions to i64 produce values JavaScript sees as BigInt when returned through
exports. And converting f32 to i64 and back loses precision for large values, independently of trapping — another reason to keep numeric types consistent
across the boundary.
Where NaN and huge values come from
Conversion traps are usually the end of a longer story. NaN arises from 0.0 / 0.0, sqrt of a negative number, inf - inf, or arithmetic on an
uninitialised value read as a float; infinities from division by zero or overflow of large products; huge finite values from unit mistakes (milliseconds treated
as seconds, pixels as micrometres) or from untrusted input such as a file header declaring a width of 1e30. When a conversion trap appears, trace the value
back to its origin rather than only guarding the cast: a degenerate transform matrix with a zero scale, an empty bounding box, a division by a count that can be
zero. Guarding the conversion fixes the crash; fixing the origin fixes the wrong output that would otherwise appear in other places. In debug builds, it is
worth adding assertions that values are finite at a few key points (after parsing input, after computing transforms), so problems are reported where they
start.
Differences between engines and platforms
The instructions are precisely specified, so all engines produce the same results for trunc and trunc_sat — Wasm removes the platform differences native
code has. Differences come from the source language and compiler instead: the same C code compiled natively for x86 may return INT32_MIN for NaN (the
hardware’s “integer indefinite” value), while the Wasm build traps; ARM natively saturates. Code ported from native platforms sometimes depends on such
behaviour accidentally — for example using NaN-to-INT32_MIN as a sentinel. Search ported code for float-to-int casts on values that can be NaN, and make the
intended behaviour explicit.
Generated code size
Without the non-trapping feature, every saturating cast expands into several instructions; modules with many conversions (graphics, audio) shrink noticeably when the feature is enabled.
Checking finiteness early
A cheap habit prevents most conversion surprises: validate floats at the boundaries where they enter the module. Reject or replace non-finite values when parsing input, and assert finiteness after computations that can produce NaN, so conversions later never see them.
Expected output
The module’s three trapping i32.trunc_f64_s instructions are traced to a C function converting canvas coordinates; the function now clamps explicitly; edge-case
tests with NaN and ±1e20 pass in the Wasm build; Rust code uses as casts that compile to trunc_sat; and a coordinate of Infinity from a degenerate
transform now draws at the edge instead of aborting the module.
Gotchas
- Assuming casts behave as on x86. Wasm traps or saturates differently. Define behaviour explicitly.
- Silent saturation hiding bugs. NaN becomes 0 quietly. Check where it matters.
- Undefined behaviour in C casts. Clamp before converting.
to_int_uncheckedin Rust. Undefined for out-of-range values. Avoid on untrusted data.- Testing only natively. Behaviour differs in Wasm. Test the Wasm build.
- Guarding only the cast. The NaN’s origin still produces wrong output elsewhere. Trace values back.
Performance note
A single trunc_sat replaces a sequence of comparisons and branches around a trapping trunc; in a pixel-conversion loop, enabling the feature reduced conversion
cost by about 30%.
Frequently Asked Questions
Is the non-trapping feature safe to enable? Yes for current engines; it has been supported broadly for years.
Do traps leave the instance usable? A trap aborts the call; whether state is consistent depends on the code. Treat the instance as suspect.
What about float-to-float conversions?
f32.demote_f64 and f64.promote_f32 never trap.
Does JavaScript’s |0 saturate?
No — it wraps modulo 2^32, a third behaviour; be careful when comparing results.
Why does the same C code behave differently natively and in Wasm? Native x86 returns a sentinel for NaN conversions and ARM saturates, while Wasm traps or saturates by specification; make intent explicit.
Where do NaN values usually come from? Zero divided by zero, square roots of negatives, infinity minus infinity, or uninitialised values read as floats.
Do all engines produce the same conversion results? Yes — the instructions are precisely specified; differences come from source languages and compilers.
Does enabling the feature shrink modules? Yes, in conversion-heavy code — each saturating cast becomes one instruction instead of a comparison sequence.
Can ported code rely on x86’s sentinel value for NaN? It should not; search for such casts and replace the implicit behaviour with an explicit check.
Should debug builds assert finiteness? Yes, at a few key points such as after parsing and after transforms, so problems are reported where they start.
Related
- Fixing memory access out of bounds traps — another trap class.
- Catching Wasm traps in JavaScript — handling traps.
- Using sign-extension operators — another small proposal.
- Following the Wasm proposal process — how features ship.
← Back to Post-MVP Wasm Proposals in Practice