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 print or wasm2wat to 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.

trunc versus trunc_sat for i32 targets For in-range values both truncate toward zero. For NaN, trunc traps while trunc_sat returns zero. For values above the maximum, trunc traps while trunc_sat returns the maximum. For values below the minimum, trunc traps while trunc_sat returns the minimum. input (f64 → i32 signed) i32.trunc_f64_s i32.trunc_sat_f64_s 3.9 3 3 -3.9 -3 -3 NaN trap 0 1e20 trap 2147483647 -1e20 trap -2147483648

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.

Tracing a conversion trap to its cause A trap reports a float value unrepresentable in integer range. Printing the module shows a trapping trunc instruction in a specific function. The source cast in that function receives NaN or an out-of-range value. Fix by clamping and handling NaN explicitly, or by using a saturating conversion, then verify with edge-case tests. trap: unrepresentable RuntimeError find trunc in WAT function name source cast receives NaN / huge clamp or saturate explicit semantics edge-case tests NaN, ±inf, ±1e20

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_unchecked in 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%.

Float-to-int conversion cost in a pixel loop Relative time for converting floats to integers in a pixel loop with saturating semantics implemented by comparisons around trapping trunc instructions, and with native trunc_sat instructions. relative conversion time compare + trapping trunc 1 × trunc_sat instruction 0.7 ×

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.

← Back to Post-MVP Wasm Proposals in Practice