Passing Optional Values and Nulls

This page answers one task: a WebAssembly function takes an optional argument or may return nothing — a lookup that finds no match, a setting that was not provided — and both sides need to agree on how “no value” is represented, without ambiguity or crashes.

Prerequisites

  • [ ] A Rust module built with wasm-bindgen, or a C/raw module with exported functions.
  • [ ] Basic familiarity with how numbers and pointers cross the boundary.

WebAssembly has no “nothing”

WebAssembly’s core value types are numbers: i32, i64, f32, f64. None of them has a value meaning “absent”. JavaScript has two — undefined and null — and Rust has Option<T>, C has null pointers and sentinel conventions. So an optional value crossing the boundary has to be encoded: as a reserved number (a sentinel), as a separate flag, as a null pointer, or — with reference types — as a null reference. Bindings generators pick an encoding automatically; hand-written APIs must choose one and use it consistently.

The two failure modes are ambiguity and accidental conversion. A sentinel of -1 for “not found” is ambiguous if -1 is also a valid result. And JavaScript’s loose conversions turn undefined into NaN or 0 when passed where a number is expected — a missing optional argument silently becomes zero inside the module.

How "no value" is encoded across the boundary Rust Option of a number is passed by wasm-bindgen as a flag plus value or a sentinel and appears as undefined in JavaScript. Option of a string or object becomes undefined. C uses sentinel numbers or null pointers. Reference types allow a true null reference. source type encoding in Wasm JavaScript sees Option<u32> / Option<f64> flag + value (glue) number or undefined Option<String> null pointer / length string or undefined Option<JsValue> heap slot or none value or undefined C pointer 0 (NULL) 0 → wrapper maps to null C index sentinel such as -1 -1 → wrapper maps to null externref ref.null None

Step 1 — use Option in wasm-bindgen signatures

wasm-bindgen supports Option<T> for parameters and return values of numbers, strings, slices, JsValue, exported classes and enums:

#[wasm_bindgen]
pub fn find_index(haystack: &[u32], needle: u32) -> Option<u32> {
    haystack.iter().position(|&x| x == needle).map(|i| i as u32)
}

#[wasm_bindgen]
pub fn resize(width: u32, height: Option<u32>) -> String {
    let h = height.unwrap_or(width);        // square if height omitted
    format!("{width}x{h}")
}
find_index(new Uint32Array([5, 7, 9]), 7);   // 1
find_index(new Uint32Array([5, 7, 9]), 8);   // undefined
resize(300);                                 // "300x300"
resize(300, undefined);                      // "300x300"
resize(300, null);                           // "300x300"

On input, both undefined and null map to None. On output, None becomes undefined. The TypeScript declarations use number | undefined and optional parameters, so the types tell callers the truth.

Step 2 — avoid the undefined-to-zero trap in raw exports

Without generated glue, a missing argument to a raw export is converted by JavaScript’s ToNumber rules: undefined becomes NaN, which becomes 0 for an i32 parameter. The module cannot tell “zero” from “not given”:

instance.exports.resize(300);            // height parameter receives 0, not "missing"

Raw exports therefore need an explicit encoding. The two robust options are an extra flag parameter and a sentinel that is outside the valid range:

/* flag + value: unambiguous for any value */
EMSCRIPTEN_KEEPALIVE int resize(int width, int has_height, int height);

/* sentinel: compact, when a value is impossible */
#define NO_HEIGHT (-1)
EMSCRIPTEN_KEEPALIVE int resize2(int width, int height /* NO_HEIGHT if absent */);

Wrap both in JavaScript so callers never see the encoding:

export const resize = (w, h) => Module._resize(w, h == null ? 0 : 1, h ?? 0);

h == null matches both null and undefined, which is what you want for “not provided”.

Step 3 — returning “not found” from C

Functions that look something up should not overload a valid value. Return a pointer that is NULL when absent, or an index with a sentinel outside the valid range, and convert in the wrapper:

export function findUser(id) {
  const ptr = Module._user_find(id);              // returns 0 if not found
  return ptr === 0 ? null : readUser(ptr);
}

export function indexOf(arr, value) {
  const i = callIndexOf(arr, value);              // returns -1 if not found
  return i < 0 ? undefined : i;
}

Address 0 is never a valid allocation in Emscripten or Rust modules — the low memory is reserved — so a null pointer is a safe sentinel. For numeric results, prefer an out-parameter flag when every value of the type is valid, such as a lookup returning arbitrary f64s.

A C lookup and its JavaScript wrapper JavaScript calls the wrapper, which calls the raw export. The export returns a null pointer when nothing matches. The wrapper converts a zero pointer to null and a valid pointer to an object, so callers never see the sentinel. findUser(42) wrapper _user_find(42) raw export returns ptr 0 if not found ptr === 0 ? sentinel check null or object what callers get

Step 4 — choose between null and undefined deliberately

JavaScript code bases often have a convention: undefined for “not provided”, null for “explicitly empty”. wasm-bindgen returns undefined for None; serde-wasm-bindgen does the same by default and can be configured with serialize_missing_as_null(true) to produce null, which matches JSON and many API conventions. Pick one for each API and document it. Comparisons with == null treat both alike, which is the safe pattern for consumers; the distinction matters mostly when values are serialised to JSON, where undefined properties disappear and null ones remain.

Step 5 — nullable references with externref

Modules that hold JavaScript objects as externref values can represent absence with a true null reference. A ref.null extern passed to JavaScript becomes null, and JavaScript null passed to an externref parameter arrives as a null reference that the module can test with ref.is_null. wasm-bindgen uses externref for JsValue when its reference-types feature is enabled, which makes Option<JsValue> an especially cheap optional. Non-nullable reference types — (ref extern) from the function-references proposal — let a signature promise that a value is never null, which engines can check at the boundary.

Optional fields in structured data

Optionality is not only about individual parameters. Structured data passed through serde has optional fields, and there are three states to distinguish: a field that is present with a value, a field that is present and explicitly null, and a field that is absent. Option<T> collapses the last two; for most APIs that is right. When the difference matters — a partial update where null means “clear this setting” and absence means “leave it unchanged” — use a double option (Option<Option<T>> with #[serde(default, with = "::serde_with::rust::double_option")]) or a dedicated three-state enum. The JavaScript side must then be careful too, because spreading objects and JSON serialisation both drop undefined properties. A small helper that builds the update object with explicit null for cleared fields avoids the class of bugs where a user clears a field in the UI and the server never hears about it. Documenting the three states in the API’s TypeScript types — field?: string | null — keeps every caller aware of the distinction.

Optional callbacks and handles

Optional arguments are not always data. An API might accept an optional progress callback, an optional AbortSignal, or an optional handle to a previously created object. wasm-bindgen handles Option<js_sys::Function> and Option<&MyClass> the same way as other options: JavaScript passes the function or instance, or undefined, and Rust receives Some or None. In raw exports, an optional callback is easiest to handle in the JavaScript wrapper — store the callback in a JavaScript variable, pass a flag into Wasm saying whether to call the progress import, and have the import check the variable. Optional handles in C APIs are naturally null pointers, again converted by the wrapper. Keeping optionality in the wrapper layer, rather than pushing every encoding detail into the module, leaves the exported surface small and predictable.

Expected output

find_index(arr, 8) returns undefined; the C-based findUser(999) returns null; calling the raw resize wrapper with no height produces a square image rather than a zero-height one; and TypeScript flags any caller that uses a possibly-undefined result without checking it.

Gotchas

  • undefined silently becoming 0. Raw exports convert missing arguments to zero. Use a flag or sentinel and a wrapper.
  • Sentinels inside the valid range. -1 as “not found” breaks when -1 is valid. Pick an impossible value or use a flag.
  • Mixing null and undefined conventions. Consumers check the wrong one. Compare with == null or standardise.
  • Losing explicit nulls in JSON. undefined properties disappear when serialised. Use null when absence must survive.
  • NaN as a sentinel. NaN !== NaN, and canonicalisation may change its bits. Use a flag for floats.

Performance note

Optional numeric parameters through wasm-bindgen cost an extra flag argument, below measurement noise in Chrome. Option<String> returning None was faster than returning an empty string, because no memory was allocated or decoded. A flag-plus-value raw export was as fast as the plain version.

Returning a missing string from Rust Nanoseconds per call to return an absent value, comparing Option String returning None, returning an empty String, and returning a short String. ns per call Option<String> → undefined 35 ns empty String 140 ns 12-character String 210 ns

Frequently Asked Questions

Does wasm-bindgen distinguish null from undefined on input? No — both become None. If you need to distinguish them, accept a JsValue and check is_null() and is_undefined().

Can I return Option<MyStruct>? Yes, for exported classes; JavaScript receives an instance or undefined.

How do I represent an optional bool? Option<bool> is supported by wasm-bindgen. In raw exports, use -1, 0, 1 or a separate flag.

Are optional u64 values supported? Yes, as bigint | undefined; see passing 64-bit integers with BigInt.

Is it better to have optional parameters or several functions? For one or two optional values, Option is clearer. For many, accept an options object through serde, which also documents the defaults in one place.

← Back to Passing Complex Types Across the Boundary