Using Sign-Extension Operators
This page answers one question: reading compiled WebAssembly, you see instructions like i32.extend8_s and i64.extend32_s, or a toolchain warns about the
“sign-ext” feature when targeting old engines. You want to know what these operators do, why they exist, where they come from in your source code, and when
you need to care about them.
Prerequisites
- [ ] Basic familiarity with two’s-complement integers.
- [ ]
wasm-tools printorwasm2watto read modules. - [ ] Code using narrow signed integers (
i8,i16,int8_t,short) or narrowing casts.
What sign extension means
WebAssembly’s value types are 32-bit and 64-bit integers; there are no 8- or 16-bit value types. Languages with narrow signed integers therefore keep them in
32-bit registers and must restore the correct value after operations that produce only the low bits. Sign extension takes the low N bits of a value, treats
bit N−1 as the sign, and copies it into all higher bits: the byte 0xF0 (−16 as i8) becomes 0xFFFFFFF0 (−16 as i32), while 0x70 (112) stays
0x00000070.
The MVP had no single instruction for this. Compilers emitted a pair of shifts: shift left so the narrow value’s sign bit lands in bit 31, then arithmetic-shift right by the same amount, which drags the sign bit down. The sign-extension operators proposal (an early post-MVP feature, supported in all current engines) added direct instructions.
Step 1 — see them in compiled code
#[no_mangle]
pub extern "C" fn add_bytes(a: i32, b: i32) -> i32 {
(a as i8).wrapping_add(b as i8) as i32 // 8-bit signed arithmetic
}
(func $add_bytes (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add
i32.extend8_s) ;; restore the i8 result as a signed i32
The addition happens in 32 bits; i32.extend8_s then makes the result behave like an i8. In C, the same appears for int8_t and short arithmetic and for
casts such as (int)(signed char)x.
Step 2 — understand loads versus extension
Memory loads already have sign-extending variants: i32.load8_s loads a byte and sign-extends it in one instruction. The new operators matter for values
already in registers — results of arithmetic, function parameters narrowed by casts, values received from JavaScript. Compilers use whichever is available:
loads with _s for memory, extension operators for computed values.
Step 3 — know when compatibility matters
Every current browser and Wasm runtime supports sign extension, and current toolchains enable it by default. It matters only if you target very old engines
(roughly pre-2020 browsers) or unusual embedded runtimes. If a module must run there, build with the feature disabled — for example -C target-cpu=mvp in Rust
(with the standard library rebuilt accordingly) or the corresponding clang -mno-sign-ext flag — and run wasm-opt with matching features. Validation
against an MVP-only feature set confirms no extension operators remain:
wasm-tools validate --features mvp app.wasm # fails if extend8_s etc. are present
Step 4 — read them when debugging
When stepping through disassembly or reading WAT, an extend8_s or extend16_s signals narrow signed arithmetic in the source — a useful clue for mapping
instructions back to code. Bugs involving narrow integers often show up as missing or unexpected extensions: a value treated as unsigned where the source meant
signed, or the reverse. Comparing the WAT with the intended types helps spot them.
Step 5 — mind the JavaScript boundary
JavaScript receives i32 results as signed numbers. A function returning a sign-extended byte returns −16 to JavaScript for 0xF0; one returning a
zero-extended byte returns 240. Make sure exported functions return what callers expect, and on the JavaScript side, use x << 24 >> 24 (sign-extend) or
x & 0xFF (zero-extend) when normalising values from linear memory or other sources.
Why such a small proposal existed
Sign extension was among the first post-MVP features because it was small, uncontroversial and immediately useful: it shrank code, made intent clearer to
engines (which could emit a single movsx on x86 or sxtb on ARM instead of recognising a shift pattern), and fixed a common pattern in compiled C and Rust. It
also served as a practical test of the proposal process for adding instructions — the same process later used for much larger features.
A worked example: decoding signed 16-bit samples
Audio and sensor data often arrive as little-endian signed 16-bit integers. A decoder reads two bytes, combines them, and must sign-extend the result before
converting to float. In Rust, i16::from_le_bytes([lo, hi]) as f32 expresses it; compiled to Wasm, the combination of bytes produces a 32-bit value whose upper
bits are zero, and i32.extend16_s turns, say, 0xFFFE into −2 before the float conversion. Reading directly from memory with i32.load16_s does both steps in
one instruction, which is what the compiler emits when the value comes straight from a buffer. If you hand-write such code in JavaScript or WAT and forget the
extension, negative samples become large positive numbers — loud clicks in audio and spikes in sensor plots are the classic symptom. Comparing a few decoded
samples against a reference decoder in tests catches it immediately.
Sign extension in hand-written WAT
When writing WAT by hand, the operators make intent explicit and code shorter. A helper that clamps a value to the signed 8-bit range, for example, can extend after an add to check for overflow:
(func $add_i8_checked (param $a i32) (param $b i32) (result i32)
(local $sum i32)
(local.set $sum (i32.add (local.get $a) (local.get $b)))
;; overflow if extending the low byte changes the value
(if (i32.ne (i32.extend8_s (local.get $sum)) (local.get $sum))
(then (unreachable)))
(local.get $sum))
The same check with shifts works in MVP engines but is harder to read; with the operator, the condition reads like its meaning.
Tooling support
All mainstream tools understand these instructions: wasm-tools, WABT, Binaryen and browser DevTools disassemble them by name. Very old versions of tools may
not; if a disassembler shows unknown opcodes in the 0xC0–0xC4 range, update it.
Testing narrow-integer code
Narrow signed arithmetic is easy to test exhaustively: there are only 65,536 pairs of i8 values and four billion 16-bit inputs per unary function. For
functions that combine narrow values, loop over all 8-bit inputs (or a dense sample of 16-bit ones) and compare the Wasm build’s results with a reference
implementation in JavaScript or native code. Exhaustive tests take milliseconds and remove any doubt about missing or extra extensions, which is far more
reliable than reasoning about each instruction.
Expected output
You can read i32.extend8_s in compiled code as “treat the low byte as a signed 8-bit value”, connect it to narrow signed arithmetic or casts in the source, know
that it replaced a two-shift sequence, verify with wasm-tools validate --features mvp whether a module depends on it, and handle signed versus unsigned narrow
values correctly at the JavaScript boundary.
Gotchas
- Confusing sign and zero extension. −16 versus 240. Check the source type.
- Targeting very old engines with default flags. The feature is on by default. Disable and validate.
- Rebuilding only your code. The standard library may use the feature. Rebuild it too.
- Normalising in JavaScript with
& 0xFFwhen signed was meant. Use shifts for sign extension. - Assuming loads and extends are interchangeable. Loads sign-extend from memory; extends work on registers.
- Hand-written decoders without extension. Negative values become large positives. Extend or use signed loads.
Performance note
In a byte-processing kernel with many narrow signed operations, enabling the feature reduced code size by about 3% and had no measurable speed difference in optimising tiers, which already recognised the shift pattern — the gain is mostly size and baseline-tier speed.
Frequently Asked Questions
Is there a zero-extension instruction?
For 32-to-64, i64.extend_i32_u; for narrower values, an and with a mask suffices.
Do these instructions trap? No — they are pure bit operations.
Does SIMD have sign extension?
SIMD has its own extend operations for lanes (extend_low/extend_high).
Why not add i8 and i16 value types? Narrow types would complicate the type system; extension instructions solve the practical need cheaply.
Why do my decoded audio samples have spikes?
Missing sign extension turns negative 16-bit samples into large positive values; use load16_s or extend16_s.
What are the opcodes?
0xC0 to 0xC4 for i32.extend8_s, i32.extend16_s, i64.extend8_s, i64.extend16_s and i64.extend32_s.
Can extension detect overflow in narrow arithmetic? Yes — if extending the low bits changes the value, the result did not fit the narrow type.
Do old disassemblers show these instructions? Very old tool versions may show unknown opcodes; update WABT, wasm-tools or Binaryen.
Can narrow-integer functions be tested exhaustively? Yes — all 8-bit input pairs take milliseconds; compare against a reference implementation.
Related
- Using non-trapping float-to-int conversions — another early proposal.
- Following the Wasm proposal process — how features are added.
- Decoding Wasm opcodes for debugging — reading instructions.
- Supporting older Safari versions with Wasm — old engines.
← Back to Post-MVP Wasm Proposals in Practice