Skip to main content

Wasm Differences

In the wasm crate we can't always go one to one with the rust crate. Here are some differences/extra types in the WASM create. AsRef From and Into are implemented to go between the rust and wasm crate types to help.

Heterogeneous Arrays

Any array of non-primitives such as [foo] will generate another type called FooList which supports all basic array operations. This array wrapper implements len() -> usize, get(usize) -> T and add(T).

The wrapper exists primarily for ownership safety, not just type-support gaps. In wasm_bindgen, passing an exported class to a function by value transfers ownership: the JavaScript object's internal pointer is nulled, and any later use of that object throws (null pointer passed to rust) — a very subtle bug class, since the object looks intact from JS. A bare Vec<Foo> parameter does this to every element of the caller's array, and wasm_bindgen does not support Vec<&Foo> (&Foo doesn't implement JsObject), so a bare-vector API has no by-reference form — the caller would have to manually .clone() each element to keep their objects alive. A FooList instead crosses the boundary as a single class passed by reference (&FooList), cloning internally, so the caller's objects are never consumed. This is why the generated API prefers references over values everywhere a class type crosses the boundary, and why collection parameters are *List classes rather than bare vectors.

Secondarily, the wrapper also covers element types wasm_bindgen can't put in a bare Vec at all: Vec<bool> (no VectorIntoWasmAbi for bool) and doubly-nested types like Vec<Vec<T>> (which includes any array of byte strings, since bytes is already Vec<u8>).

Text arrays do not get a list wrapper: wasm_bindgen exposes Vec<String> directly (a JS string array, in both parameter and return position), and strings are copied at the boundary, so the ownership hazard above doesn't apply to them. Anonymous [* text] positions therefore surface as bare Vec<String> — including text-keyed tables' keys() returns. (This changed generated signatures for consumers that previously saw a TextList class, but it is the correct API — a copied-at-the-boundary element type has nothing for a wrapper to protect.) Named text-array rules like texts = [* text] still surface as a class under their own identifier, like any other named rule.

Tables

Map literals also generate a type for them with new(), len() -> usize, insert(K, V) -> Option<V>, get(K) -> Option<V> and keys() -> Vec<K>. An anonymously-inlined map member gets a structural MapKeyToValue name derived from its Key and Value types. A named table rule always surfaces as a JS class under its own identifier, independent of what else the spec contains. When that rule is the sole owner of its map shape, the structural MapKeyToValue name becomes a rust-source-level pub type alias to the rule's class (wasm_bindgen does not export type aliases, so anonymous/embedded uses of the same shape resolve to the rule's JS class); if two or more named rules share one shape, each still gets its own class and anonymous inline uses of that shape get the structural MapKeyToValue class.

Non-empty containers (the two-wrapper pattern)

A non-empty container — [+ T]NonEmptyVec<T>, {+ k => v}NonEmptyMap<K, V> (see Output format) — crosses the wasm boundary as two classes, because JS cannot see the rust type-level guarantee and cannot hand a Vec over by value without consuming its elements:

  • The loose wrapper is the builder. It is exactly the ordinary collection wrapper the loose form would generate (BarList for [+ bar], MapKToV for {+ k => v}), with the same new()/add()/insert()/get()/len() API — no new machinery. You fill it incrementally.
  • The restricted wrapper wraps the real core type (NonEmptyBarList(cddl_lib::NonEmptyVec<...>)) and is created via try_from(list: &BarList) -> Result<Self, JsError>, which borrows and clones: cloning sidesteps the ownership hazard entirely, so the JS-side loose BarList stays valid after the conversion. The throw happens at try_from, right where the mistake is — not deep inside a parent constructor. When the element's loose form is directly exposable ([+ uint]Vec<u64>, [+ text]Vec<String>), no loose wrapper exists and try_from takes the bare Vec<...> by value instead (boundary copies, no ownership hazard).
  • Parent constructors and setters take the pre-checked restricted wrapper by reference (Foo.new(…, tags: &NonEmptyBarList)), so Foo.new stops being a throw site for the container bound — the same infallibility win as the rust side. Getters return a clone of the restricted wrapper.
  • Mutation follows the bound: add/insert stay infallible (a push can never violate a minimum bound), while removal throws at the bound. add therefore mirrors the loose wrapper's add.
const list = BarList.new();
list.add(bar);
const foo = Foo.new(hash, counter, NonEmptyBarList.try_from(list));

One corner: a self-named rule whose identifier is the element's loose-builder name (bar_list = [+ bar], where [* bar] would already mint BarList) emits the restricted wrapper without try_from — the ident legitimately owns that name, so there is no separate loose class to borrow from, and construction is new(first) + add (the reason is in the wrapper's generated doc comment). See --wasm-list-macro for the macro-mode posture of these restricted wrappers.

Enums

Both type/group choices generate rust-style enums. On the wasm side we can't do that so we directly wrap the rust type, and then provide a FooKind c-style enum for each rust enum Foo just for checking which variant it is.

Tag and @newtype wrappers

A CBOR tag over a non-struct type (t = #6.10(uint)) and a ; @newtype rule both generate a wrapper struct. Both the rust and wasm bindings expose a full boundary surface: new(inner) to construct the wrapper from its inner value, and an inner-value getter. The getter is named get by default; an @newtype <name> comment renames it (e.g. ; @newtype get_val emits get_val instead of get). For a bounded/range wrapper (h = uint .le 10) the inner value can fail the bound check, so the wasm new returns Result<T, JsError> rather than T (the rust new returns Result<Self, DeserializeError>). The wrapper's CBOR bytes are still available via to_cbor_bytes() / from_cbor_bytes(): for a plain @newtype those bytes are exactly the inner value's bytes; for a tag they include the tag header.

Two shapes interact with this surface specially:

  • A wrapper over a named array or map takes and returns that collection's own wasm class as its inner value — e.g. a @newtype over foos = [* foo] gets new(inner: &FooList) / get() -> FooList, and the FooList class carries the full new/add/get/len collection API. So you build the collection, hand it to new, and read it back with get.
  • A tag directly over an inline struct (t = #6.20([a: uint, b: text])) is folded into the struct itself — no wrapper is generated, and the struct keeps its normal per-field constructor and getters. (A tag over a named struct rule, t = #6.14(foo), still produces a wrapper with new/get.)

Nullable / nested Options

wasm_bindgen cannot expose a doubly-nested Option<Option<T>> (Option<T> does not implement OptionIntoWasmAbi), the same way it can't expose a nested Vec. This happens when a nullable value (T / nullOption<T>) sits in a position that adds its own presence-Option: a map value (get/insert return Option<value>) or an optional struct field (its getter returns Option<field>). In those spots the accessor flattens to a single Option<T>, so a read returns None for both an absent entry and a present-but-null one (the same convention the c-style enum as_*() getters use). The underlying rust type keeps all three states (absent / present-null / present-value), so CBOR round-trips are unaffected — only the wasm read side conflates absent with null.

To recover the three states on the read side without changing any existing getter signature, additive presence accessors are emitted right beside the flattening getter. Use these read protocols:

  • Optional-nullable struct field — the getter field0() is joined by has_field0() -> bool (outer presence):
    • has_field0() == false → the field is absent.
    • has_field0() == true && field0() == None → the field is present but null.
    • has_field0() == true && field0() == Some(v) → the field holds value v.
  • Nullable map valueget(key) is joined by has(key) -> bool (key presence, a direct key lookup rather than scanning keys()):
    • has(key) == false → the key is absent.
    • has(key) == true && get(key) == None → the key is present with a null value.
    • has(key) == true && get(key) == Some(v) → the key holds value v.
  • Single-nested nullable enum variant (… / (T / null)) — no extra accessor is needed, because kind() already resolves the variant unambiguously. as_variant() flattens, but kind() tells you it is the variant, so a None from the getter can only mean inner-null:
    • kind() != <variant> → a different variant.
    • kind() == <variant> && as_variant() == None → this variant, inner null.
    • kind() == <variant> && as_variant() == Some(v) → this variant, holding value v.

A doubly-nested nullable enum variant (a payload resolving to Option<Option<T>>, e.g. text / ((uint / null) / null)) is not a case you can hit: the generated wasm enum constructor cannot accept such a payload, so no supported CDDL produces one.

Write-side semantics

The flattening is a read-side effect only; the write side is fully three-state expressive. A nullable setter/insert takes the inner Option<T> as its argument and stores it wrapped in the outer presence-Option, so all three states are constructible directly from JavaScript:

  • Optional-nullable struct field (set_field0(value) stores self.0.field0 = Some(value)):
    • absent — never call the setter (the constructor leaves the field None).
    • present-nullset_field0(null) stores Some(None).
    • present-valueset_field0(v) stores Some(Some(v)).
  • Nullable map value (insert(key, value) stores value directly as the entry):
    • absent key — never insert the key.
    • present-nullinsert(key, null) stores a null entry.
    • present-valueinsert(key, v) stores v.

(The only thing a setter can't do is remove a slot back to absent once set — but that state is the constructor default and needs no setter.) So unlike the read side, no fidelity is lost on write, and there is no need to detour through the rust crate to build a present-null value.

JSON and large integers

With --json-serde-derives a wasm type exposes two ways to produce JSON: to_json() returns a string (via serde_json) and to_json_value() returns a live JS value (via serde-wasm-bindgen). They agree for every value inside JavaScript's safe-integer range (|n| ≤ 2^53 − 1).

Above that, a uint (Rust u64) can hold values JavaScript's number cannot represent exactly, and the two paths intentionally differ:

  • to_json() is always lossless — the full-precision integer is written into the JSON string.
  • to_json_value() fails loud — it throws (... can't be represented as a JavaScript number) instead of silently returning a rounded number. serde-wasm-bindgen's json_compatible mode does not emit BigInt (a BigInt would not survive JSON.stringify and would change the JS type of every integer field), so refusing is the safe behaviour.
  • JSON.parse(to_json()) is lossy for these values — that is a property of JavaScript's native JSON.parse, not of the generated code.

If you need full precision above 2^53 on the JS side, take to_json() and parse it with a BigInt-aware JSON parser rather than JSON.parse / to_json_value().