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.

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

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 → a Signers class); an anonymous generic-collection instance uses the structural names <Elem>OrderedSet / NonEmpty<Elem>OrderedSet (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.
  • 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.

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 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), no constructor argument and no setter. Its own entry count is t.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_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.

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.