Validating Inputs Before They Reach Wasm
This page answers one task: callers pass bad inputs to a WebAssembly function — a negative size, a string instead of a number, an array larger than memory, a malformed structure — and instead of a clear error they get a trap, a silent wrong result, or a crash deep inside the module. You want cheap checks at the boundary that reject bad inputs with messages callers understand.
Prerequisites
- [ ] A Wasm module with a JavaScript wrapper (hand-written or generated).
- [ ] Knowledge of each exported function’s preconditions.
- [ ] Tests that call the API with invalid inputs.
Why bad inputs become traps
JavaScript is dynamically typed and forgiving; WebAssembly is neither. When JavaScript calls a Wasm export, arguments are converted to Wasm’s numeric
types with JavaScript’s conversion rules: undefined becomes NaN and then 0 for integer parameters, "12" becomes 12, 3.7 is truncated to 3,
and 2**32 + 5 wraps to 5 for an i32. No error is raised; the function runs with values the caller did not intend. A length that wrapped to a small
number reads the wrong amount of data; a pointer that became 0 reads from address zero; a size computed from a negative number becomes a huge unsigned
value that triggers an allocation failure. The result surfaces later as a trap — “memory access out of bounds”, “unreachable” — far from the real cause.
Generated glue helps but does not cover everything. wasm-bindgen checks that a value passed as &str is a string in debug builds and converts types
it knows, but cannot know that a u32 “width” must be non-zero or that two arrays must have the same length. Those preconditions belong in a validation
layer — and the cheapest place for most of them is JavaScript, before crossing the boundary.
Step 1 — check types and ranges in the wrapper
Write a thin validation function per export. Check type, integer-ness and range for every numeric argument that becomes a Wasm integer:
function assertU32(name, v, { min = 0, max = 0xffff_ffff } = {}) {
if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) {
throw new RangeError(`${name} must be an integer in [${min}, ${max}], got ${typeof v === "number" ? v : typeof v}`);
}
}
export function resize(image, width, height) {
if (!(image instanceof Uint8Array)) throw new TypeError("image must be a Uint8Array");
assertU32("width", width, { min: 1, max: 16384 });
assertU32("height", height, { min: 1, max: 16384 });
return wasm.resize(image, width, height);
}
Limits should reflect what the module can handle — maximum dimensions that fit in memory — not only what the type allows.
Step 2 — check lengths and relationships
Many traps come from inconsistent combinations rather than single bad values. Check them explicitly:
export function mix(left, right, gains) {
if (left.length !== right.length) throw new RangeError(`left and right must have equal length (${left.length} vs ${right.length})`);
if (gains.length !== 2) throw new RangeError("gains must have exactly two values");
if (left.length * 4 * 3 > maxBytesAvailable()) throw new RangeError("input too large for available memory");
return wasm.mix(left, right, gains);
}
Check alignment and byte length for typed arrays that the module will reinterpret: a Float32Array view requires a byte offset that is a multiple of 4,
and a buffer of bytes intended as f32 samples must have a length divisible by 4.
Step 3 — validate untrusted data inside the module
The wrapper validates arguments; it should not try to validate the contents of complex data such as files, network messages or user documents. A
parser in JavaScript that pre-validates a file format duplicates the module’s parser and can disagree with it. Untrusted data is validated where it is
parsed — inside the module — and the module must return errors, not trap, for every malformed input. Use checked arithmetic for sizes and offsets read
from data (checked_mul, checked_add), bounds-checked slicing, and fallible allocation for sizes the data controls. Fuzzing is how you find out
whether that holds.
Step 4 — validate structured objects with a schema
For functions that take configuration objects converted with serde-wasm-bindgen or Embind value objects, validate the object against a schema before
conversion. Conversion errors from serde are accurate but terse (“invalid type: string, expected u32”); a schema validator such as Zod, Valibot or a JSON
Schema validator gives paths and messages: options.quality: expected number between 0 and 100. Generate the schema from the Rust types where possible
(for example with schemars producing JSON Schema), so the two cannot drift apart.
import { z } from "zod";
const EncodeOptions = z.object({
quality: z.number().int().min(0).max(100),
progressive: z.boolean().default(false),
colorSpace: z.enum(["srgb", "display-p3"]).default("srgb"),
});
export function encode(pixels, width, height, options) {
const opts = EncodeOptions.parse(options); // throws with a path on failure
return wasm.encode(pixels, width, height, opts);
}
Step 5 — keep checks cheap and test them
Validation in hot paths must not cost more than the work. Checks of a few scalars cost nanoseconds; scanning a large array to validate every element does not belong in the wrapper of a function called per frame. For hot functions, validate once when data is set up (when a buffer is registered or a configuration applied) rather than per call. Then test the checks: for every precondition, a test that violates it and asserts the error type and message, so validation does not quietly disappear in a refactor.
TypeScript is not validation
Type declarations generated for the module help callers at compile time, but they vanish at runtime and do not constrain values from JSON, user input or
untyped code. width: number accepts NaN, -1 and 1.5. Use types for documentation and editor help, and runtime checks for anything that crosses a
trust or module boundary. Branded types or a parse step that returns a validated object can bridge the two, so validated values carry a type that
proves they were checked.
Debug-only and production checks
Some checks are too expensive for production but invaluable during development: verifying that a typed array’s values are finite, that indices are
within a mesh’s vertex count, that a sorted input is actually sorted. Put them behind a development flag — if (import.meta.env.DEV) in Vite, or a
debug build of the wrapper — and run the test suite with them on. Keep cheap checks that prevent traps in production; a clear error is worth far more
than the nanoseconds saved.
Validating values coming back from Wasm
The boundary has two directions. Values returned from a module can also be wrong — a pointer and length pair that does not fit in memory after a bug, an
enum discriminant outside the known range, a u32 that JavaScript reads as negative. Wrappers that build typed-array views from returned pointers
should check that ptr + len fits within memory.buffer.byteLength before creating the view, and convert unknown enum values into an explicit error
rather than passing undefined onwards. These checks are cheap, catch module bugs at the boundary where they are easy to attribute, and keep a bug in
the module from becoming a confusing failure in unrelated JavaScript code.
Expected output
resize(img, undefined, 600) throws RangeError: width must be an integer in [1, 16384], got undefined instead of trapping; mix with unequal lengths
throws a message naming both lengths; a malformed file returns { code: "TRUNCATED" } from the module; encode with quality: 150 throws
options.quality: Number must be less than or equal to 100; and each precondition has a test.
Gotchas
- Relying on numeric conversion.
undefinedsilently becomes 0. Check types. - Validating file contents in JavaScript. Duplicates the parser. Validate inside the module.
- Per-element scans in hot paths. Too expensive. Validate at setup time.
- Trusting TypeScript types at runtime. They are erased. Check values.
- Untested checks. They disappear in refactors. Test each precondition.
Performance note
Scalar argument checks added 0.03 µs per call to resize; validating an options object with a schema added 1.8 µs per call to encode, negligible next
to encoding but worth caching for per-frame calls.
Frequently Asked Questions
Doesn’t wasm-bindgen validate types already? For some types in debug builds; it does not know your domain rules.
Should the module validate again? For untrusted data, yes — always. For arguments, the wrapper is enough if every caller goes through it.
What error types should I throw?
TypeError for wrong types and RangeError for out-of-range values, matching JavaScript conventions.
How do I validate in a worker-based API? Validate on the main-thread side before posting, so errors are synchronous and close to the caller.
Should I validate what the module returns? Cheaply, yes — check pointer and length pairs against memory size and map unknown enum values to explicit errors.
Related
- Fixing memory access out of bounds traps — what unchecked inputs cause.
- Fuzzing a Wasm module — testing internal validation.
- Adding context to errors from Wasm — useful error reports.
- Testing error paths across the boundary — tests for every error.
← Back to Errors & Traps Across the Boundary