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.

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:

MethodEmitted 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 (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). The test is the LOOSE FORM's exposability, not the element's own: bytes and bool cross the boundary fine as single values but have no bare-Vec form at all (see above), so [+ bytes] / [+ bool] take &BytesList / &BoolList like any other wrapper-needing element.
  • 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.

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 → a Signers class); an anonymous generic-collection instance uses the structural names <Elem>OrderedSet / NonEmpty<Elem>OrderedSet / bounded <Elem>BoundedOrderedSet… (e.g. U64OrderedSet for an inline reject [* uint], NonEmptyFooOrderedSet for [+ foo]) — the uniqueness analogue of the <Elem>List list 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 loose FooList stays valid), or try_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 over bytes or bool also enters through &BytesList / &BoolList.
  • The one JS-visible difference from the non-empty wrappers: add is fallible. A non-empty wrapper's add is infallible (a push can never violate a minimum bound), but a reject wrapper's add returns Result<(), JsError> and throws when the element is already present — the same duplicate refusal try_from performs, 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, so Foo.new is 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 → a Meta class); a sole-owner rule's structural PairMap<K>To<V> name becomes a pub type alias to it, so anonymous uses of the shape resolve to the same JS class.
  • A tagged or @newtype preserve 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 preserve emits a TaggedMeta class whose new takes — and whose getter returns — a PairMapU64ToBytes class the crate mints beside it (indexed in collections.rs like every structural wrapper; the default-flavored MapU64ToBytes is minted beside it too, exactly as a @newtype set rule mints both its list and set classes). The {+} flavor's boundary is the restricted NonEmptyPairMap<K>To<V> class through the same door.
  • The structural name encodes the container. A synthesized preserve wrapper is PairMap<K>To<V> (PairMapU64ToText for {* uint => text} ; @duplicates preserve) and its non-empty flavor is NonEmptyPairMap<K>To<V> — the PairMap prefix composes with the NonEmpty one exactly as the containers do. The default flavor keeps Map<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 the NonEmptyPairMap core and is entered via try_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) or new(first_key, first_value). Parent constructors/setters take it by reference.
  • The JS-visible difference from the reject-set wrappers: insert is add's opposite. A reject set's add returns Result<(), JsError> and throws on a duplicate; a pair-map's insert(key, value) returns Option<V> and APPENDS — it never throws and never rejects, because preserving duplicates is the whole point. It always returns None (nothing is displaced, since nothing is overwritten); the Option<V> return exists only so the surface matches the loose table's insert. get returns 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 @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.)

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-style AnyCborKind enum (variants UInt, NInt, Bytes, Text, Array, Map, Tag, Bool, Null, Undefined, Unassigned, Float) for checking which CBOR item is held, mirroring the FooKind pattern used for enums.
  • to_json() / from_json(json: &str) — emitted only under --json-serde-derives, per the any JSON representation. The AnyCbor wasm class itself renders the tagged value codec; every other generated wasm type that contains an any — a member, a table range, a list element, an any choice arm — renders its to_json() naturally, since its to_json() is serde_json over the rust value and the rust serde flip carries through. A generated type's to_json() is therefore fallible on data: a contained any holding bytes, a tag, undefined, a non-finite float, or a complex/colliding map key makes it return a JsError naming 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 itselflen(), insert(key, value), get(key), keys(), and has(key) when V_t is nullable, each delegating to the typed container. A JS read is t.get(k), not a two-layer t.entries().get(k). This is the same call the 258-set nominal makes and for the same reason: a wasm class has no Deref, 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 for K_r/V_r (MapMdToMd, or PairMapMdToMd under @duplicates preserve). Its record-level insert_rest(key, value) -> Result<(), JsError> mutates that catch-all parent row; the wrapper returned by rest() remains a snapshot. Loose rows have no constructor argument; restricted rows cross as a checked wrapper. Its own entry count is t.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_t class is not rejected — it is unrepresentable;
  • the class's own method names are taken, so the catch-all row may not be @named get, has, insert, keys or len. 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 / 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), 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 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.

.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 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().

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.