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

Sign-extension instructions i32.extend8_s and i32.extend16_s sign-extend the low 8 or 16 bits of an i32. i64.extend8_s, i64.extend16_s and i64.extend32_s do the same within an i64. Before the proposal, each required a left shift followed by an arithmetic right shift. instruction extends low bits replaces i32.extend8_s 8 → 32 i32.shl 24 + i32.shr_s 24 i32.extend16_s 16 → 32 i32.shl 16 + i32.shr_s 16 i64.extend8_s 8 → 64 i64.shl 56 + i64.shr_s 56 i64.extend16_s 16 → 64 i64.shl 48 + i64.shr_s 48 i64.extend32_s 32 → 64 i64.shl 32 + i64.shr_s 32

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.

Before and after the sign-extension proposal Before the proposal, sign-extending a narrow value in a register required a left shift and an arithmetic right shift, two instructions plus constants. With the proposal, a single extend instruction does the same, giving smaller code and a clearer intent for engines to compile. MVP (shift pair) i32.const 24, i32.shl i32.const 24, i32.shr_s 4 instructions with constants works everywhere sign-ext proposal i32.extend8_s 1 instruction maps to one machine instruction smaller, clearer

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 & 0xFF when 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.

Code size with and without sign-extension operators Relative code size of a byte-processing module compiled without the sign-extension feature, using shift pairs, and with it, using single extend instructions. relative code size shift pairs (MVP) 1.0 × extend instructions 1 ×

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.

← Back to Post-MVP Wasm Proposals in Practice