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.
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.
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
undefinedsilently becoming 0. Raw exports convert missing arguments to zero. Use a flag or sentinel and a wrapper.- Sentinels inside the valid range.
-1as “not found” breaks when-1is valid. Pick an impossible value or use a flag. - Mixing
nullandundefinedconventions. Consumers check the wrong one. Compare with== nullor standardise. - Losing explicit nulls in JSON.
undefinedproperties disappear when serialised. Usenullwhen absence must survive. NaNas 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.
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.
Related
- Serializing data with serde-wasm-bindgen — optional fields in structured data.
- Passing enums across the boundary — another tagged encoding.
- Returning error codes without exceptions — sentinels used for errors.
- Passing JS objects to Rust with wasm-bindgen — JsValue and null checks.