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.
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.
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 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). 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.
Reject-duplicates containers
A collection rule carrying @duplicates reject — [* T] → OrderedSet<T>,
[+ T] → NonEmptyOrderedSet<T> (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(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. - 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.
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.
There is no rest constructor argument and no setter (v1): the field defaults empty and is
mutated through the returned wrapper's own insert/get/keys API. 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]) captures its trailing
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 tail's
element type (AnyList for an * any tail, BytesList for * bytes, or a bare Vec for a
wasm-native element) — the same list-wrapper surface as any homogeneous array. As with the map rest
row, there is no constructor argument and no setter (v1): the tail defaults empty and is
mutated through the returned wrapper's own API.
An @ignore rest row or rest tail stores nothing, so its wasm class has
no rest() getter — it is a plain closed struct's wasm surface. The read-tolerance (unknown
entries / trailing 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), no constructor argument and no setter. Its own entry count ist.rest().len().
new() is nullary — both rows default empty, exactly as a rest row does on a closed struct.
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.
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.