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 (
BarListfor[+ bar],MapKToVfor{+ k => v}), with the samenew()/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 viatry_from(list: &BarList) -> Result<Self, JsError>, which borrows and clones: cloning sidesteps the ownership hazard entirely, so the JS-side looseBarListstays valid after the conversion. The throw happens attry_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 andtry_fromtakes the bareVec<...>by value instead (boundary copies, no ownership hazard). - Parent constructors and setters take the pre-checked restricted wrapper by reference
(
Foo.new(…, tags: &NonEmptyBarList)), soFoo.newstops 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/insertstay infallible (a push can never violate a minimum bound), while removal throws at the bound.addtherefore mirrors the loose wrapper'sadd.
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
@newtypeoverfoos = [* foo]getsnew(inner: &FooList)/get() -> FooList, and theFooListclass carries the fullnew/add/get/lencollection API. So you build the collection, hand it tonew, and read it back withget. - 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 withnew/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 / null → Option<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 byhas_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 valuev.
- Nullable map value —
get(key)is joined byhas(key) -> bool(key presence, a direct key lookup rather than scanningkeys()):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 valuev.
- Single-nested nullable enum variant (
… / (T / null)) — no extra accessor is needed, becausekind()already resolves the variant unambiguously.as_variant()flattens, butkind()tells you it is the variant, so aNonefrom 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 valuev.
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)storesself.0.field0 = Some(value)):- absent — never call the setter (the constructor leaves the field
None). - present-null —
set_field0(null)storesSome(None). - present-value —
set_field0(v)storesSome(Some(v)).
- absent — never call the setter (the constructor leaves the field
- Nullable map value (
insert(key, value)storesvaluedirectly as the entry):- absent key — never insert the key.
- present-null —
insert(key, null)stores anullentry. - present-value —
insert(key, v)storesv.
(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 roundednumber.serde-wasm-bindgen'sjson_compatiblemode does not emitBigInt(aBigIntwould not surviveJSON.stringifyand 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 nativeJSON.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().