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.
This document covers the wasm-bindgen face (wasm/). The component-model face
(component/, under --component=true) has its own sibling document with the same
charter: Component (WIT) differences.
The boundary door: CBOR and JSON methods
wasm_bindgen cannot export traits, so the runtime trait methods a rust consumer would call through
Serialize / ToCBORBytes / Deserialize (and the serde derives) are re-exported on each generated
wasm class as inherent methods. Six of them are flag-conditional, and each one is emitted exactly
where the generated runtime declares its backing:
| Method | Emitted when |
|---|---|
to_cbor_bytes() -> Vec<u8> | --to-from-bytes-methods (on by default) |
from_cbor_bytes(bytes: &[u8]) -> Result<T, JsError> | --to-from-bytes-methods, and the type got a generated deserializer |
to_canonical_cbor_bytes() -> Vec<u8> | --to-from-bytes-methods and --preserve-encodings --canonical-form |
to_json() -> Result<String, JsError> | --json-serde-derives |
to_json_value() -> Result<JsValue, JsError> | --json-serde-derives |
from_json(json: &str) -> Result<T, JsError> | --json-serde-derives |
to_canonical_cbor_bytes is the one gated on a flag pair, because the method is declared on the
Serialize trait the runtime composes only under --preserve-encodings --canonical-form; every
other posture composes a ToCBORBytes declaring to_cbor_bytes alone.
The door belongs to classes that wrap a rust type. A collection wrapper class (FooList,
MapTextToFoo, …) carries its collection API (new/add/get/len/insert/keys) and no door
member in any posture — cross the boundary through the owning type instead.
A named fixed-value rule is one of those owning classes, even though its Rust value is zero-sized:
answer = 42 (or magic = h'CAFE') exports an opaque class with the CBOR/JSON door, and
maybe-answer = 42 / null uses that class for its present value. This keeps a singleton's constant
validation and any tag or bytes .cbor framing at the same boundary as its Rust codec; it is not a
wasm-visible C-style enum.
Under --wasm-cbor-json-api-macro the generator emits your macro invocation in place of all six, so
the door's contents there are the macro's contract rather than the generator's.
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).
These lowering rules apply by spelling, not by construct: an anonymous plain (non-258)
generic-collection instance (x: xs<foo> for xs<a> = [* a]) crosses the wasm boundary exactly
as its inline equivalent (x: [* foo]) would — the structural FooList class when the element
needs a wrapper, or the direct exposure below when it doesn't — while a NAMED collection rule
(foos = [* foo]) exposes a class under its own rule name.
A named rule may deliberately claim a structural name: writing foo_list = [* foo] makes your rule
be the FooList class, so a table's keys(), a rest row's keys(), a rest tail and every inline
[* foo] all share it — the list-side counterpart of a sole-owner table rule aliasing MapKeyToValue
(below). The two must agree on the element: a rule of that ident with any other shape is rejected
by name at generation time, naming the use that mints the class, rather than shadowing it.
Nested restricted arrays keep the restricted child's identity in their structural class name and
carrier: [* [* uint]] uses ArrU64List over Vec<Vec<u64>>, while [* [*5 uint]] uses
U64ListMax5List over Vec<BoundedVec<u64, 0, 5>>. Outer occurrence bounds compose with that
identity as well, so loose, outer-bounded, inner-bounded, and doubly bounded shapes cannot select one
wrapper according to traversal order.
Table keys() keeps the established structural spelling even when the key is a restricted
collection: keys of type [* uint], [+ uint], and [2*5 uint] all return ArrU64List. The map's
insert/get doors still take the appropriate loose, non-empty, or bounded key wrapper; the
returned keys list projects cloned restricted keys to their loose boundary values. A bounded table's
loose MapKeyToValue construction door follows the same rule and re-checks each key when converting
to the named restricted table. A named bounded table establishes the loose direct-array source for
its structural MapKeyToValue shape across the finalized IR, so a nested use cannot make that source
depend on traversal order. An anonymous bounded table with no such named owner instead retains its
native carrier when it is another table's key. In either case, an outer table key remains the native
restricted wrapper — including nested map/list/set/pair-map carriers such as BoundedVec — rather
than recursively loosening the whole value. An ordinary loose map of the same structural name as a
named bounded-table source is rejected: one wasm class cannot simultaneously be the loose conversion
source and the native infallible parent-boundary carrier. This preserves existing JS names and
authored key constraints without assigning one class name to incompatible native representations.
A structural class is not always minted here, though: when its element/key/value is a type this
crate declares _CDDL_CODEGEN_EXTERN_TYPE_ or _CDDL_CODEGEN_RAW_BYTES_TYPE_ and a sibling wasm
crate already publishes that class,
@extern_companions makes the generated code reference the
sibling's class (use <crate>::<Class>;) instead — one JS class for one concept, and no duplicate
#[wasm_bindgen] symbol when both crates link into a single cdylib. The dependency-keyed
--extern-wrapper-index / --workspace-dep flags do the same for wrappers
over types owned by a declared extern dependency.
A 258 set is instead nominal (see current capacities, tag-set
section): a generic instance mints one class per instantiation — set<foo> mints its own
#[wasm_bindgen] class SetFoo (over the rust nominal), distinct from the inline plain [* foo]
structural FooList — and an inline #6.258([* foo]) set occurrence (member, element, map
key/value, generic arg) likewise mints a shape-derived nominal class (SetFoo), deduped one per
inline shape rather than the transparent FooList. A named binding of an instance
(named = set<foo>) is a passthrough pub type Named = SetFoo;. The nominal's new(inner) still
uses the structural collection wrapper (FooList, or the <Elem>OrderedSet reject twin) for element
crossing, so the structural companion class is not eliminated — it is the nominal's construction
boundary. The read/mutate surface is FLATTENED onto the nominal class itself: because the wasm
class has no Deref, it delegates len(), indexed get(index) -> Foo, insert(elem) -> bool (the
std-set door), add(elem) (the checked door), contains(elem) -> bool, try_from(<Foo>List), and
the empty-means-absent try_opt_from(...). A JS read is set.get(i), not the two-layer
set.get().get(i). Class re-key note for consumers: a spec whose generic set instances (or
inline #6.258 occurrences) previously surfaced as structural *List / *OrderedSet passthrough
aliases now surfaces one nominal class per instantiation/shape; downstream JS/--wrapper-requests
code that imported the synthesized name as a structural alias should move to the nominal class (the
structural wrapper name itself is unchanged and still own-produced). A rule-name binding of an
instance (required_signers = nonempty_set<key>) has no wasm class of its own — wasm-bindgen exports
no type aliases — so JS call sites re-key from the rule name to the nominal class name; the tool
emits a typescript_custom_section export type RequiredSigners = NonemptySetKey; so TypeScript
type positions keep compiling (JS value positions still re-key to the nominal class).
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 a non-exact bytes value is already Vec<u8>).
An exact bytes .size N is [u8; N] in Rust but still crosses wasm as a Vec<u8>: getters copy
the array into that list, while a direct constructor may keep the loose list door and collection
add/insert paths return JsError if their stored-carrier handover finds a wrong length.
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 the full boundary identities of its Key and Value types. That derivation is recursive: nested array/map occurrence restrictions and a nested map's duplicate policy contribute identifier-safe fragments, so incompatible native carriers never select one JS class; wholly loose descendants retain their established spelling. 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.
One established boundary exception applies when K is itself a restricted array or map. A bounded
table's checked try_from builder and a table's keys() list use the key's loose outer collection
form; only that outer occurrence/duplicate-policy restriction is erased. Restrictions nested inside
the key remain in the structural name and carrier, while the native table and its insert/get
surface keep the fully restricted key type.
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). The test is the LOOSE FORM's exposability, not the element's own:bytesandboolcross the boundary fine as single values but have no bare-Vecform at all (see above), so[+ bytes]/[+ bool]take&BytesList/&BoolListlike any other wrapper-needing element. - 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.
Bounded homogeneous arrays
An ordinary or @duplicates preserve bounded homogeneous array ([? T], [*N T], [N* T], or
[N*M T]) uses the same loose-builder/restricted-wrapper pattern and is named <Elem>ListMaxN, <Elem>ListMinN, or
<Elem>ListMinNMaxN (a named CDDL rule keeps its own class name). try_from borrows and clones the
loose list into the core BoundedVec::TryFrom<Vec<_>> door for variable windows; add returns Result because it can
cross an upper bound. An exact N*N ordinary/preserve wrapper instead stores [T; N] and its
try_from makes the one Vec<T> → array handover with the standard RangeCheck; it has neither
new() nor add(), since its cardinality cannot be mutated. Its public JS/WIT list identity is
unchanged. Only zero-minimum variable classes expose an infallible empty new() seed; positive-
minimum classes have no new. A zero-minimum self-named rule whose identifier is already the
loose builder (bar_list = [*5 bar]) has no separate try_from source; it remains constructible
through new() plus checked add, and its generated docs say so. A positive-minimum self-named
rule is rejected with a rename remedy because it has neither that empty seed nor a distinct loose
source. A bounded @duplicates reject array instead uses the parallel
<Elem>BoundedOrderedSetMaxN / …MinN / …MinNMaxM class over BoundedOrderedSet; try_from and
checked add enter its compound door, and len/get/contains remain readable while normalizing
insert is intentionally absent.
Bounded homogeneous tables
A unique-key bounded table ({ ? K => V }, { N*M K => V }, { N* K => V }, or an omitted
exact-once occurrence) owns a restricted MapKToVMaxN, MapKToVMinN, or MapKToVMinNMaxM class
(an authored same-shape rule owns the inline surface). It wraps BoundedMap; try_from(looseMap)
and checked insert re-enter the core range-check door, and only a zero-minimum class has new().
* and + retain their loose and non-empty APIs. A bounded @duplicates preserve table instead
uses the identically-windowed PairMapKToVMaxN / …MinN / …MinNMaxM class over
BoundedPairMap; its loose PairMapKToV source is borrowed by try_from, and its checked insert
appends duplicate entries without mutation on range failure.
Reject-duplicates containers
A collection rule carrying @duplicates reject — [* T] → OrderedSet<T>,
[+ T] → NonEmptyOrderedSet<T>, and every other bounded window → BoundedOrderedSet<T, MIN, MAX> (see
Output format) — crosses the
wasm boundary with the same two-wrapper pattern as the non-empty case, the restricted wrapper
wrapping the uniqueness twin core:
- A named rule surfaces as a class under its own identifier (
signers = [* key] ; @duplicates reject→ aSignersclass); an anonymous generic-collection instance uses the structural names<Elem>OrderedSet/NonEmpty<Elem>OrderedSet/ bounded<Elem>BoundedOrderedSet…(e.g.U64OrderedSetfor an inline reject[* uint],NonEmptyFooOrderedSetfor[+ foo]) — the uniqueness analogue of the<Elem>Listlist wrapper. - Construction is via
try_from, exactly like the non-empty restricted wrapper:try_from(list: &FooList) -> Result<Self, JsError>for a wrapper-needing element (borrows and clones, so the looseFooListstays valid), ortry_from(elements: Vec<T>)by value when the element's loose form is directly exposable ([* uint]→Vec<u64>) — the same loose-form test as the non-empty wrapper, so a reject set overbytesorboolalso enters through&BytesList/&BoolList. - The one JS-visible difference from the non-empty wrappers:
addis fallible. A non-empty wrapper'saddis infallible (a push can never violate a minimum bound), but a reject wrapper'saddreturnsResult<(), JsError>and throws when the element is already present — the same duplicate refusaltry_fromperforms, raised at the exact call site of the mistake. Getters return a clone of the restricted wrapper, and parent constructors/setters take it by reference, soFoo.newis not itself a duplicate-throw site.
Preserve-duplicates tables
A table rule carrying @duplicates preserve — {* k => v} → PairMap<K, V>,
{+ k => v} → NonEmptyPairMap<K, V> (see
Output format) — crosses the wasm
boundary as a JS class, and the {+} flavor uses the same two-wrapper pattern as the non-empty
and reject cases (a loose builder plus a restricted try_from-door wrapper):
- The loose
{* k => v}wrapper exposes exactly the ordinary map-wrapper surface —new(),len(),insert(key, value) -> Option<V>,get(key) -> Option<V>,keys() -> Vec<K>. A named rule surfaces under its own identifier (meta = {* uint => bytes} ; @duplicates preserve→ aMetaclass); a sole-owner rule's structuralPairMap<K>To<V>name becomes apub typealias to it, so anonymous uses of the shape resolve to the same JS class. - A tagged or
@newtypepreserve table rule is a nominal wrapper (it owns its tag — see current capacities), so its own class carries the wrapper surface (to/from_cbor_bytes,new(inner), a getter) rather than the map surface, and its boundary types are the flavored structural class:tagged_meta = #6.24({* uint => bytes}) ; @duplicates preserveemits aTaggedMetaclass whosenewtakes — and whose getter returns — aPairMapU64ToBytesclass the crate mints beside it (indexed incollections.rslike every structural wrapper; the default-flavoredMapU64ToBytesis minted beside it too, exactly as a@newtypeset rule mints both its list and set classes). The{+}flavor's boundary is the restrictedNonEmptyPairMap<K>To<V>class through the same door. - The structural name encodes the container. A synthesized preserve wrapper is
PairMap<K>To<V>(PairMapU64ToTextfor{* uint => text} ; @duplicates preserve) and its non-empty flavor isNonEmptyPairMap<K>To<V>— thePairMapprefix composes with theNonEmptyone exactly as the containers do. The default flavor keepsMap<K>To<V>/NonEmptyMap<K>To<V>. Because the name carries the flavor, a preserve and a non-preserve map of the identical key/value are two distinct JS classes rather than one class asked to be two shapes. - The
{+ k => v}restricted wrapper wraps theNonEmptyPairMapcore and is entered viatry_from(map: &PairMap<K>To<V>) -> Result<Self, JsError>(the min-1 door over the loose wrapper of the SAME flavor, borrowing and cloning so the loose map stays valid) ornew(first_key, first_value). Parent constructors/setters take it by reference. - The JS-visible difference from the reject-set wrappers:
insertisadd's opposite. A reject set'saddreturnsResult<(), JsError>and throws on a duplicate; a pair-map'sinsert(key, value)returnsOption<V>and APPENDS — it never throws and never rejects, because preserving duplicates is the whole point. It always returnsNone(nothing is displaced, since nothing is overwritten); theOption<V>return exists only so the surface matches the loose table'sinsert.getreturns the FIRST entry for the key.
Every shape that generates for rust generates for wasm here — the loose {*} PairMap wrapper and the
{+} NonEmptyPairMap wrapper both cross the boundary.
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.)
The any type (AnyCbor)
CDDL any lowers to the AnyCbor runtime value, which
surfaces on the wasm boundary as a #[wasm_bindgen] AnyCbor class wrapping the rust runtime type.
Its v1 surface is intentionally byte-oriented rather than field-destructuring:
from_cbor_bytes(bytes: &[u8]) -> Result<AnyCbor, JsError>/to_cbor_bytes() -> Vec<u8>— the CBOR round-trip door (the same pair every wasm wrapper carries).kind() -> AnyCborKind— a c-styleAnyCborKindenum (variantsUInt,NInt,Bytes,Text,Array,Map,Tag,Bool,Null,Undefined,Unassigned,Float) for checking which CBOR item is held, mirroring theFooKindpattern used for enums.to_json()/from_json(json: &str)— emitted only under--json-serde-derives, per theanyJSON representation. TheAnyCborwasm class itself renders the tagged value codec; every other generated wasm type that contains anany— a member, a table range, a list element, ananychoice arm — renders itsto_json()naturally, since itsto_json()isserde_jsonover the rust value and the rust serde flip carries through. A generated type'sto_json()is therefore fallible on data: a containedanyholding bytes, a tag,undefined, a non-finite float, or a complex/colliding map key makes it return aJsErrornaming the node kind.
Value-destructuring accessors (as_uint(), as_bytes(), …) are deliberately not on the wasm
class in v1 (they follow demand): to inspect a value, read kind() and take its bytes or JSON.
The RFC 8610 expected-conversion names eb64url, eb64legacy, and eb16 surface as their own
tagged-wrapper classes (PreludeEb64url, PreludeEb64legacy, PreludeEb16) around this same
AnyCbor class. Each has new(inner: &AnyCbor) / get() -> AnyCbor plus its own
to_cbor_bytes() / from_cbor_bytes() pair; the wrapper bytes retain and require its fixed
#6.21/#6.22/#6.23 tag while the inner byte door is one arbitrary CBOR item. These are not
base64/base16 text converters — the tags carry CBOR rendering advice only. Their JSON posture is the
natural tagged-any wrapper posture described in Output format.
Containers of any surface their ordinary List/Map wrapper classes: [* any] is AnyList (full
new/add/get/len array API), and a table is MapAnyToAny / MapU64ToAny / MapAnyToU64 for
the { * any => any } / { * uint => any } / { * any => uint } domain-range combinations (the same
insert/get/keys surface as any other table wrapper). A top-level x = any alias surfaces no
distinct wasm class of its own — the AnyCbor class is its wasm face (the one place any skips the
rust/wasm type-alias parity, since a bare AnyCbor alias would add nothing).
Open struct-map rest rows
An open struct-map ({ 1: uint, * K => V }) captures
unknown entries into a rest field. On the wasm boundary that field is a read-only rest() getter
returning the minted map wrapper for the row's K/V shape (MapU64ToAny, MapAnyToAny, …, or the
PairMap-backed wrapper under @duplicates preserve) — the same map-wrapper surface as any table.
For a loose * / 0* row there is no rest constructor argument: the field defaults empty.
The returned wrapper is a detached snapshot, so its insert/get/keys API changes that wrapper
rather than the parent record. To mutate the record, call its explicit
insert_<row>(key, value) -> Result<(), JsError> method (insert_rest by default). It converts at
the wasm boundary and updates the native parent itself: ordinary maps replace an equal key, while
@duplicates preserve appends a pair. A restricted + / 1* / bounded row still crosses new as
its complete checked structural wrapper, and the parent insertion method takes the carrier's checked
path so a maximum failure leaves the record unchanged. An exact-zero fixed member changes the loose
constructor case too: its open record takes the complete rest wrapper at fallible new; its parent
method delegates to the native insert_<row> validation door, so forbidden keys surface as
JsError without changing the record. The declared members keep their ordinary per-field
constructor arguments and getters.
A typed key domain (* K => V with K a rule of your own rather than uint/text/any) adds no
new name family: the getter returns the same structural class named after K's and V's wasm faces
(MapMdToText, or PairMapMdToText under @duplicates preserve), and that wrapper's keys() returns
what a table on the same key type returns — a bare Vec when K is wasm-native, the minted
<K>List class (MdList, BytesList) otherwise.
An open array ([uint, tstr, * t],
[uint, tstr, + t], or a safe middle form [uint, * bytes, tstr]) captures its repeated elements
into a rest field the same way. On the wasm boundary that field is a read-only getter named
after the field (rest() by default) returning the minted list wrapper for the segment's
element type (AnyList for an * any segment, BytesList for * bytes, or a bare Vec for a
wasm-native element) — the same list-wrapper surface as any homogeneous array. A loose * segment
has no constructor argument and defaults empty. A non-empty + / 1* segment instead adds a
first-element constructor argument and returns the corresponding restricted non-empty list wrapper
(NonEmptyBytesList, for example), so the wasm API cannot create an empty segment. A bounded
segment uses its matching checked list wrapper. The carrier/getter surface is the same for final
tails, variable major-disjoint middle placement (whether its effective heads are generator-proven or
declared by a transparent custom alias), finite disjoint fixed-domain same-major middle placement,
and exact count-delimited middle placement; neither
flavor has a setter.
An @ignore rest row or occurrence segment stores nothing, so its wasm class has
no rest() getter — it is a plain closed struct's wasm surface. The read-tolerance (unknown
entries / final or safe-middle elements decode and are dropped) comes for free through the rust
deserializer, so the wasm boundary adds nothing new.
Open tables
An open table — a named rule
t = { * K_t => V_t, * K_r => V_r }, one typed row plus one trailing typed catch-all — mints a real #[wasm_bindgen] class under the rule's own name. That
is a difference from a plain table worth stating up front: a table lowers to a transparent
pub type on the rust side, while an open table is a struct, so its CDDL rule name reaches JS
directly rather than through a structural MapKToV class.
The class carries two different surfaces, one per row:
- the typed row's map surface is FLATTENED onto the class itself —
len(),insert(key, value),get(key),keys(), andhas(key)whenV_tis nullable, each delegating to the typed container. A JS read ist.get(k), not a two-layert.entries().get(k). This is the same call the 258-set nominal makes and for the same reason: a wasm class has noDeref, so a container getter would cost every read an extra hop. There is deliberately no whole-map getter for this row. - the catch-all row keeps the ordinary
rest-row contract: a read-only
rest()getter returning the minted map wrapper forK_r/V_r(MapMdToMd, orPairMapMdToMdunder@duplicates preserve). Its record-levelinsert_rest(key, value) -> Result<(), JsError>mutates that catch-all parent row; the wrapper returned byrest()remains a snapshot. Loose rows have no constructor argument; restricted rows cross as a checked wrapper. Its own entry count ist.rest().len().
For the loose * form, new() is nullary and both rows default empty. A typed + / 1* row is the
exception: new(first_key, first_value) enters the native NonEmptyMap (or preserve-mode
NonEmptyPairMap) field directly, so the flattened typed surface has no operation that can empty it;
the catch-all remains an empty-capable rest() wrapper.
A bounded typed row remains checked at this boundary. Its new takes a loose typed-row builder and
returns Result, including for a zero-minimum finite window: it converts that builder to the native
BoundedMap/BoundedPairMap before construction. Its flattened insert also returns Result when
a finite maximum would be exceeded. A restricted catch-all (and an open-struct rest row) is a checked
structural wrapper passed whole to the fallible constructor; it is never a loose map a JS caller could
use to erase the window.
keys() returns what a table on the same key type returns: a bare Vec when K_t is wasm-native,
the minted <K_t>List class otherwise. The list is named after the key's use-site identifier, not
its resolved one, so two aliases of one underlying type key two open tables under two distinct list
classes — which is what lets one of them be borrowed from a dependency (via
@extern_companions or --extern-wrapper-index) while the other
mints locally.
Because the typed row's surface is flattened, that row mints no map wrapper class at all; only the catch-all's container appears in the crate's wrapper index. Two consequences:
- a name collision between a rule and the typed row's would-be
MapK_tToV_tclass is not rejected — it is unrepresentable; - the class's own method names are taken, so the catch-all row may not be
@namedget,has,insert,keysorlen. Doing so is a graceful generation error naming the reserved set (it applies only when wasm bindings are generated — a rust-only crate has no such class).
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), and both the CBOR and the JSON surfaces keep them distinct (Optional members whose type is nullable) — 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.
.default members read and write plain
A .default-carrying optional member (? b: uint .default 0) is the one optional field whose
getter does not return an Option. The rust struct stores such a member plain — the default
substitutes for an absent value, so there is no absence to report — and this face mirrors that
storage: the getter returns the bare value and set_b(value) assigns it directly (no presence
wrap). The consequence: absent and explicitly set to the default are indistinguishable on
this face, there is no has_b() companion, and no setter can put the member back to absent. That
is the rust face's contract showing through rather than a wasm-face simplification. (Under
--preserve-encodings the rust crate does remember whether the member was written on the wire, so
a decode/re-encode round trip stays byte-exact — that memory simply is not part of this face's
surface.) This cannot combine with the nullable flattening above: a .default lands only on a
primitive-backed head, and a nullable (T / null) head refuses the control with a message.
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().
A hand-written Serialize must be honest in the serde data model
to_json_value() is serde::Serialize::serialize(&self.0, &serde_wasm_bindgen::Serializer::json_compatible()). That is correct for any type whose Serialize describes itself truthfully to whatever serializer it is handed — but a Serialize impl is free to describe a number as something that is not a number, and one very common building block does exactly that.
serde_json::Number is dishonest under serde_json/arbitrary_precision. With that cargo feature on, Number's Serialize emits a one-field struct named $serde_json::private::Number holding the raw decimal string. Only serde_json's own serializer collapses that token back into a number; every other serializer — serde-wasm-bindgen included — emits it verbatim. serde_json::Value delegates its number arm to Number, so anything whose Serialize routes through a serde_json::Value inherits this, at every magnitude:
Thing.to_json() // {"int": 1000} (looks fine)
Thing.to_json_value() // {"int":{"$serde_json::private::Number":"1000"}} (a token, not a number)
Cargo unifies features across a build graph, so one crate anywhere in your workspace turning arbitrary_precision on is enough — your own manifest need not mention it. Building a serde_json::Value is the natural shape for a hand-written JSON encoding, so this lands squarely on _CDDL_CODEGEN_EXTERN_TYPE_ and @custom_json types.
The fix: route the final step through the shipped json_value_ser module. Under --json-serde-derives every generated rust crate carries json_value_ser.rs, which renders a serde_json::Value honestly — arm for arm identical to serde_json's own impl except that a number is emitted through serde's real integer/float methods:
impl serde::Serialize for MyExternType {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let value: serde_json::Value = self.to_json_value_of_my_own();
// instead of `serde::Serialize::serialize(&value, serializer)`:
crate::generated::json_value_ser::serialize_json_value(&value, serializer)
}
}
The module also exposes serialize_json_number (the number arm alone, for an impl that already holds a Number) and JsonValueSer<'a> (a Serialize-implementing view, so a Value composes inside serde containers). serialize_json_value's signature is #[serde(serialize_with = "…")]-compatible. Under --common-import-override the module lives in the shared runtime crate, so spell it <that_crate>::json_value_ser::…; under --export-static-crate it is one of the files written into your hand-owned crate root.
This strengthens the 2^53 contract above rather than bending it. A big integer that previously bypassed to_json_value()'s refusal entirely — handing JavaScript a lossless-but-unusable token object — now reaches that refusal like any other u64, so the loud failure stays loud and everything at or below the safe-integer cliff becomes a real JS number. to_json() is byte-identical either way: each arm only substitutes an integer or float whose re-printed decimal spelling equals the Number's own, so a value the serde data model cannot hold losslessly (a decimal carrying more precision than f64, an integer beyond ±2^127) keeps serde_json's token and keeps its exact bytes.
Generated code needs no change from you: the any-typed member adapters route through this already.