Comment DSL
We have a comment DSL to help annotate the output code beyond what is possible just with CDDL.
@name
On an exactly-zero fixed map member (0*0 key: value or *0 key: value), @name names the
otherwise-absent member's JSON property for the forbidden-key diagnostic; it does not create a Rust,
wasm, WIT, or schema field. @doc, custom codec/encoding directives, and .default are rejected in
that slot because no value field exists to carry their effect; put documentation on the containing rule
instead.
For example in an array-encoded group you can give explicit names just by the keys e.g.:
foo = [
bar: uint,
baz: text
]
but with map-encoded structs the keys are stored and for things like integer keys this isn't very helpful e.g.:
tx = {
? 0: [* input],
? 1: [* outputs],
}
we would end up with two fields: key_0 and key_1. We can instead end up with fields named inputs and outputs by doing:
tx = {
? 0: [* input], ; @name inputs
? 1: [* outputs], ; @name outputs
}
Note: the parsing can be finicky. For struct fields you must put the comment AFTER the comma, and the comma must exist even for the last field in a struct.
It is also possible to use @name with type choices:
foo = 0 ; @name mainnet
/ 1 ; @name testnet
An explicit @name must itself become a spellable ASCII Rust identifier after the relevant field,
type, or variant name conversion, and it cannot be a Rust keyword. For example, ; @name self on a
choice arm would emit the reserved variant name Self, so generation rejects it with the arm and
rename remedy instead of passing an invalid token to rustfmt. Choose another @name; the directive
changes only the generated API name, not the CBOR wire value or key.
Every explicitly named arm of one type choice must have a distinct generated Rust variant
name. The comparison is after Rust-name conversion, so my_arm and myArm collide; generation
rejects both the exact duplicate and that camel-case convergence with a diagnostic naming the choice
context (the rule where one exists), both arms, and the shared variant. Give the arms distinct
; @name values.
This is a deliberate breaking behavior change: a pre-existing duplicate no longer silently emits
Mainnet plus Mainnet2. Names the generator derives (because no @name was written) still take
numeric suffixes when they collide, and an explicit name always keeps its spelling even if its
derived sibling comes first.
For a fixed literal arm, @name is optional even when the literal's old display-derived name is
not spellable Rust. Spellable legacy names remain stable (5 → I5, true → True, "x" →
X, h'CAFE' → BytesCAFE); only an unspellable one falls back to a canonical fixed-value
identity (-1 → Nint1, 1.5 → Float3FF8000000000000, "self" → Text73656C66). The
fallback is exact for floats (IEEE-754 bits) and text (UTF-8 bytes), including empty,
punctuation-only, and digit-led text. An explicit name still wins over a colliding derived name,
so use @name only when an authored public spelling is preferred.
and also for group choices:
script = [
; @name native
tag: 0, script: native_script //
; @name plutus_v1
tag: 1, script: plutus_v1_script //
; @name plutus_v2
tag: 2, script: plutus_v2_script
]
Note: a group-choice arm that keeps more than one non-fixed field is emitted as a struct of its
own, named after the arm. Such an arm therefore competes for a type name with every rule and every
other such arm, and a spec that gives one name to two different shapes is rejected with a clean
error naming both claimants — rename either with @name. Two arms that spell out the identical
shape are one type written twice and simply share a single generated struct, so reusing generic arm
names (first/second, key/value) across rules stays fine. An arm slim enough to be inlined
into its enum variant (at most one non-fixed field) emits no struct at all and so never competes:
credential's ; @name Script arm may sit alongside a script rule, giving Credential::Script
and Script independently.
Note: separately from the type name above, every arm of one group choice names a variant of the
same generated enum, so within a single rule the arms' names must be distinct — including two arms
whose shapes are identical, and including two spellings that produce the same name (my_arm and
myArm). Giving two arms of one rule the same @name is rejected with a clean error naming both;
rename either. Arms you do not name are named for you (from the arm's member key, its type, or
its position), and such a derived name simply takes a numeric suffix when it collides — foo = [ x: uint // x: text ] gives Foo::X and Foo::X2. A name you wrote always wins over a derived one,
whichever arm comes first.
Note: @name is also the remedy for a field name the generator refuses. A field whose emitted
identifier is a Rust keyword ({ if: uint }) or a reserved generated-local name (raw, len,
read, … — see Reserved field names for the set and where each applies)
would emit a crate that does not compile, so it is rejected at parse time; ; @name <other> renames
the Rust field and leaves the CBOR wire key untouched (a bareword/text key stays the same text, and
an array position never puts the name on the wire). The refusal reads the resolved name, so
; @name raw is itself refused while raw: uint ; @name payload is accepted.
Note: @name does not rename a top-level rule or group — the rule identifier itself is the
emitted type name, so foo = uint ; @name bar is rejected with a clean error. To change the
emitted type name, rename the foo identifier. The same rejection covers the two rule shapes that
look like they have something to name and do not: a T / null rule (which collapses to an
Option<T> alias, so its arms are not variants) and the tag-258 set idiom
(foo = #6.258([* uint]) / [* uint], which collapses to a set wrapper for the same reason).
A directive that names a type or a rule belongs on the rule that defines it, and writing
one in a member's trailing slot — an ordinary field of either representation, a single-entry
group-choice arm's entry, an open-map rest row, or an open-array rest tail — is a hard error
naming the rule spelling, never a silent no-op. That covers both rule-scoped families: the
extern/collection tags (@copy, @raw_bytes_flavor, @used_as_elem, @extern_companions,
@duplicates, @ignore) and the type-scoped ones (@rust_name, @newtype, @no_alias,
@used_as_key, @custom_json, @no_json_schema_export). What a member's slot does carry
depends on the member: an ordinary field's takes its @name, its @doc and the complete
custom-codec pair; a rest row's takes its row-level directives; a single-entry arm's entry slot is
narrower still (below).
On a plain group rule the trailing comment belongs to the last group entry, not the rule:
grp = (a: uint) ; @name other renames the field a to other (exactly like
grp = (a: uint ; @name other)) — it neither renames the group nor errors, provided some rule
splices the group. A group nothing splices emits neither a struct nor a field, so nothing there
can be renamed and the rule-position rejection applies (see
Plain groups nothing splices).
That slot is read twice, and deliberately: it is the last entry's, and it is the only slot the
parser gives a group rule for its own directives. So the rule-scoped tags written there
(@rust_name, @newtype, @no_alias, @used_as_key, @custom_json, @no_json_schema_export) are
read at rule level — grp = (a: uint, b: uint) ; @used_as_key gives the spliced group's struct
its comparison derives — and are not subject to the member-position refusal below. The exemption
is the last entry only: written on any earlier entry of the same group they are member-position
directives like any other, and refused. A type rule shares nothing: a comment after the closing ] or } reaches the rule slot
alone, so keyed = [a: uint, b: text] ; @used_as_key is a rule directive and the member walk never
sees it.
Note: a group-choice arm has two comment slots, and they are not interchangeable. The slot that
names and documents the arm is the one that follows the // opening it (the ; @name native lines
above); the slot at the end of an entry's own line is that entry's, exactly as in a record. On a
multi-entry arm the difference is invisible in practice, because the arm mints a record and the
entry slot is an ordinary field slot. On a single-entry arm it is not: the arm registers no
record — the entry's type goes straight into the enum variant — so the entry slot has no field to
carry a field-level directive, and every one written there is a hard error naming the slot that
works. @doc on the entry is refused with the arm's own slot (// ; @doc …) as the remedy; the
custom (de)serializer pair is refused with the member's type rule as
the remedy; and the rule-scoped tags (@copy, @raw_bytes_flavor, @used_as_elem,
@extern_companions, @duplicates, @ignore) and the type-scoped ones (@rust_name, @newtype,
@no_alias, @used_as_key, @custom_json, @no_json_schema_export) get the same rejections they
get at an ordinary field. The one thing the entry slot does carry on such an arm is @name
naming an anonymous heterogeneous inline array or fixed-field map member type (immediately below) — that naming door is what the
"Anonymous groups not allowed" error advertises, so it stays. It is scoped to exactly the member
types that door covers (an anonymous heterogeneous inline array or fixed-field map, bare or tag-wrapped): written on a member whose
type is anything else — a homogeneous [* uint], a primitive, a named rule, or an inline table — the
name is read by nothing, and it is a hard error naming the arm's own slot (// ; @name <n>) as the
remedy. The arm's own name always comes from the arm slot; the entry slot never names the variant.
Note: @name also names an anonymous heterogeneous inline array or fixed-field map record used as a member's type, which is the remedy
the "Anonymous groups not allowed" error advertises:
t = [0, [1, bytes] ; @name inner
]
mints struct Inner and holds it in a field named inner — the trailing comment is the member's
own slot, so the single directive names both the struct and the field, which is what makes the
struct referenceable at all.
The same door applies to a heterogeneous fixed-field map record:
t = { point: { x: uint, y: uint } ; @name point
, label: text }
This mints struct Point and is equivalent to the named-rule spelling point = { x: uint, y: uint }
followed by point: point. A homogeneous inline table ({ * uint => uint }) remains a structural
map carrier, not a record, so there is no nominal struct for this slot to name.
An open table ({ * K1 => V1, * K2 => V2 }) is also outside this door: its two-container owner,
keys list, and cross-face surface are named from a top-level rule, so hoist it and reference that
rule.
Tags are transparent to this door. A tag mints no type of its own — it wraps whatever its payload is — so the anonymous array or map behind it is still the only thing the name can mean:
t = [ point: #6.42([x: uint]) ; @name tagged_point
, label: text ]
mints struct TaggedPoint, holds it in a field named tagged_point, and writes the tag over it.
Any number of nested tags (#6.1(#6.42([x: uint]))) works the same way. This emits exactly what
the named-rule spelling emits (p = [x: uint], then #6.42(p)), so the two are interchangeable —
pick whichever reads better.
The naming door is scoped to the case where the anonymous array or map is the member's whole type up
to those tag wrappers. Behind a control operator (bytes .cbor [1, bytes] ; @name inner) the array
belongs to the operator rather than the member, and the anonymous-group error stands; the same
holds when the member's type is a choice (a / [x: uint]), tagged or not, since the name would be
ambiguous between the arm and the member. An inline composite outside that position still has no
naming door: a .cbor payload, generic argument, map key, or multi-choice member must use an
explicit named rule. Homogeneous inline tables remain structural maps rather than named records.
@rust_name
@rust_name <Ident> pins the emitted Rust type name of a rule. This is the deliberate
opposite of @name: where @name renames a field/variant and never touches the top-level type
(above), @rust_name renames the top-level type — but only across the crate boundary, and
only on a rule in an extern-dependency (_CDDL_CODEGEN_EXTERN_DEPS_DIR_) scope. On a
normally-generated (exported) rule it is rejected with a clean error (there, the identifier already
is the emitted type name — rename the identifier, exactly as for @name).
Its purpose is cross-crate name fidelity. When a consumer references a dependency's type, it
otherwise re-derives the Rust name from the CDDL identifier using its own codegen version's
naming rules — which can differ from the version that actually built the dependency (naming rules
evolve). @rust_name records the dependency's final name once, at the point it is knowable, so the
consumer reads it instead of guessing:
plutus_data = _CDDL_CODEGEN_EXTERN_TYPE_ ; @rust_name PlutusData
coin = uint ; @rust_name Coin
Honoring is a boundary-only translation — every internal spelling keeps the consumer-derived name, and only the import seam differs, emitting an alias:
use plutus_dep::PlutusData as PlutusDatum; // when the consumer would derive `PlutusDatum`
so the rest of the generated code (and the wasm↔rust conversions) keep referring to the derived
name. A pin-less rule falls back to today's consumer-side derivation (hand-stub compatibility). A
pin that camel-cases to a reserved Rust std/prelude type (Option, Box, …) or a CDDL keyword is
rejected, exactly as a derived rule name by that spelling would be — a dependency could never have
emitted a type by that name, so the pin could never be honored.
@newtype
With code like foo = uint this creates an alias e.g. pub type Foo = u64; in rust. When we use foo = uint ; @newtype it instead creates a pub struct Foo(pub(crate) u64); (the wrapped field is crate-visible so hand-written modules in the same crate can reach it — see the wrapper-field contract; external crates go through the getter and new).
The wrapper always emits an inner-value getter, named get by default:
impl Foo {
pub fn get(&self) -> u64 {
self.0
}
}
@newtype can optionally rename that getter e.g. foo = uint ; @newtype custom_getter emits custom_getter instead of get. Every wrapper struct (bare tag, @newtype, and bounded/range wrappers) exposes this getter plus a new(inner) constructor in both the rust and wasm bindings.
Exception — nominal set rules (#6.258 sets). A named non-generic tag-258 set rule is itself a nominal wrapper owning its encodings (see current capacities), so @newtype on it is about the getter only. A bare @newtype on such a rule emits no inherent get() — a 0-argument get(&self) would shadow the inner collection's get(index) reached through Deref (a compile error at every indexed read), so it is deliberately suppressed; inner access goes through Deref/DerefMut and IntoIterator instead. A custom @newtype <name> getter (; @newtype entries) IS emitted (a custom name does not shadow get(index)). So on a set nominal, bare @newtype is a documented no-op and @newtype <name> adds a named accessor.
@newtype is applied for you on a recursive collection rule. A named collection whose cycle contains no nominal type cannot be emitted as a pub type alias at all (rustc E0391), so the generator marks every collection-backed rule of such a cycle @newtype itself and says so on stderr — see recursive types. Writing the directive yourself produces byte-identical output and silences the notice.
@newtype is redundant on a top-level tag rule (foo = #6.42(text), and equally on its collection and T / null bodies — #6.24([* uint]), #6.11({* tstr => uint}), #6.10(uint / null)) and on a top-level bytes .cbor T rule (foo_bytes = bytes .cbor foo): both already auto-wrap — into the tag-writing wrapper and the byte-string-framing wrapper respectively — so their standalone to/from_cbor_bytes API does not drop the tag, or write the payload unwrapped (see current capacities). Adding ; @newtype there produces the same wrapper, byte for byte, not a double wrapper — since the wrapper already has a default get, its only remaining use on such a rule is to rename that getter. Note the boundary on the T / null case: it is the tag that forces the wrapper, so an UNTAGGED T / null rule (opt = uint / null ; @newtype) still collapses to a transparent Option<T> and still rejects the directive loudly.
A named fixed-value rule is already a nominal singleton TypeChoice with its own codec, including a
fixed rule whose complete chain includes a mandatory tag or bytes .cbor. @newtype on that rule
is therefore rejected loudly, rather than accepted as an inert second nominal layer. The same
rule applies to a fixed/null singleton owner; ordinary non-fixed T / null keeps the transparent
alias boundary described above.
@no_alias
foo = uint
bar = [
field: foo
]
This would normally result in:
pub type Foo = u64;
pub struct Bar {
field: Foo,
}
but if we use @no_alias it skips generating an alias and uses it directly e.g.:
foo = uint ; @no_alias
bar = [
field: foo
]
to
pub struct Bar {
field: u64,
}
Beside a @custom_serialize/@custom_deserialize pair on a transparent alias, @no_alias is accepted and redundant: it asks for exactly the type-projection suppression a pair-carrying alias already performs, so both are honored and the rule generates byte-identically with or without it.
@used_as_key
foo = [
x: uint,
y: uint,
] ; @used_as_key
cddl-codegen derives the comparison/hash traits a type needs to serve as a map/set key. For any type
used as a CBOR map key in the spec it infers this automatically; @used_as_key forces the same
derives onto a type even when the spec never keys a map on it — useful when your own utility code puts
the type in a map and you want the generated code to already implement the traits (and, because it
lives in the spec, your hand-written mod.rs files stay untouched across regenerations).
Flavors
Bare @used_as_key derives the tool's full internal key bundle: Eq, PartialEq, Ord, PartialOrd —
plus Hash under --preserve-encodings (that mode's maps are hash-ordered). This bundle is
mode-dependent and matches exactly what an auto-detected CBOR map key gets.
When your downstream code only needs one family, name it — the flavor is mode-independent (an
external HashMap/BTreeMap requirement exists regardless of the encoding flags):
transaction_output = alonzo_format_tx_out / conway_format_tx_out ; @used_as_key hash
On a multi-choice type rule the tag must sit on the LAST arm. That arm's trailing comment is the
rule-position slot; on any other arm the tag is read as that variant's own metadata and rejected.
The one-line spelling above cannot get this wrong — a ; comment runs to end of line, so it is
always the last arm's — but a multi-line spelling can, and a reorder can move the wrong arm into
last place. See @custom_json for the general rule.
| Tag | Derives |
|---|---|
@used_as_key (bare) | Eq, PartialEq, Ord, PartialOrd (+ Hash under --preserve-encodings) |
@used_as_key hash | Hash, Eq, PartialEq |
@used_as_key ord | Ord, PartialOrd, Eq, PartialEq |
@used_as_key hash ord | the union of both |
Naming a flavor lets a type that keys a HashMap downstream avoid an Ord it can never supply (e.g.
a hand-written extern with no total order), instead of failing to compile far from the cause.
Demand is a union. Flavors and the internal bundle can only add derives, never remove them:
tagging a type hash cannot strip the Ord it gets from also being an auto-detected CBOR map key.
The flavor propagates transitively to every type the tagged type contains, exactly like the bare tag.
Strict vocabulary. Only hash and ord may follow @used_as_key. Any other word — a typo, or
trailing prose like @used_as_key marks the output — is a hard error; put prose in @doc.
Compile-time assertions. Every @used_as_key-tagged type — flavored or bare — gets a named
_demand_<rule> function in the generated crate (generated/key_demand_assertions.rs) that asks
the compiler to prove the type supplies the traits its tag demands (for bare, the mode-dependent
full bundle). Beyond turning a distant trait error into a near, named one, the file is the
in-crate breadcrumb from a failing derive back to its cause: demand propagates transitively, so
"why does this struct need Ord?" is answered by the demand roots this file enumerates, each
citing its tag. (Auto-detected CBOR map keys emit no assertion — the generated containers' own
bounds already enforce them.)
Version-skew hazard. A cddl-codegen older than this feature silently ignores the flavor word and
falls back to the full bundle, so a spec relying on a narrow flavor to avoid Ord (etc.) regenerates
the original failure under an old tool. Pin your tool version in-repo. (Requires cddl-codegen with
@used_as_key flavor support.)
Rule kinds. The tag demands derives on the struct a rule mints, so it belongs on a rule that
mints one — including a plain group spliced into a rule that materializes it
(foo = (a: uint, b: uint) ; @used_as_key with holder = [foo]), which honors it on the record
struct the splice produces. On a generic definition (foo<T> = [x: T] ; @used_as_key) it is
rejected: a definition names no concrete type, so there is nothing to derive on — put it on the
instantiating rule (inst = foo<uint> ; @used_as_key). On a transparent alias
(foo = uint, foo = (uint), a named binding to a set nominal) it is accepted and adds nothing:
the alias has no struct of its own, and each of those targets already supplies the key traits.
@used_as_elem
bootstrap_witness = [
vkey: bytes,
signature: bytes,
] ; @used_as_elem
When you generate WASM bindings, a list used inline in the spec — [* bootstrap_witness] — mints a
loose-list wrapper class (BootstrapWitnessList) so the elements can cross the WASM boundary safely.
But if no rule in your spec actually contains that list, the wrapper is never generated, even though
hand-written downstream code (or another crate) may still want to construct one.
@used_as_elem forces the generator to mint that loose-list wrapper for the tagged type exactly as
if the spec contained an inline [* bootstrap_witness] usage: the structural class
BootstrapWitnessList, its entry in wasm/src/generated/collections.rs, and its registration as an
own-spec-produced shape (so a downstream --wrapper-requests consumer asking for [* bootstrap_witness]
is satisfied by your crate's class instead of minting its own).
The idiomatic use case is being the canonical host of a wrapper class for a type your crate owns: a
downstream crate points --extern-wrapper-index/--workspace-dep at you and imports BootstrapWitnessList
from your collections module rather than re-minting a colliding #[wasm_bindgen] class. Before this
tag you had to add a throwaway "fake" rule (bootstrap_witness_list = [* bootstrap_witness]) purely to
force the class into existence; the tag expresses the intent directly on the element type and avoids
the extra rust-side pub type alias the fake rule would emit.
Notes:
- It is a no-op without
--wasm(the wrapper is a WASM-boundary concern only). - It is rejected if the element is directly WASM-exposable (e.g. a transparent
uint/textalias): such a list lowers to a bareVec<..>at the boundary with no wrapper class, so there is nothing to mint. - It only mints the loose
[* x]list wrapper.[+ x](NonEmpty), bounded array wrappers, and map wrappers are out of scope (a map wrapper cannot be named by a tag on a single element rule). - It is rule-scoped: the tag names the type whose wrapper to mint, so it belongs on a rule. On a
field/member's trailing comment (
f: bw, ; @used_as_elem, inside aholder = [ … ]) it is a hard error — put it on the rule that defines the element type instead (bw = [...] ; @used_as_elem). - On a generic definition (
foo<T> = [x: T] ; @used_as_elem) it is rejected: a definition names no concrete type, so there is no element type for a wrapper to hold. Put it on the instantiating rule (inst = foo<uint> ; @used_as_elem), which is where the concrete type is minted.
@duplicates
@duplicates reject is live for set/array collection rules ([* a] / [+ a] / [n*m a], including the
tag-258 set idiom), across the Rust, JSON, wasm-bindgen, and component/WIT boundaries: the rule's
rust representation becomes an order-preserving, duplicate-free set and each boundary preserves
that invariant. @duplicates preserve is live for table rules ({ * k => v } and
{ + k => v }, including finite/exact occurrence windows) across the Rust, JSON, wasm-bindgen, and component/WIT boundaries: the rust alias
becomes a byte-exact, duplicate-keyed pair-map — PairMap<K, V>, NonEmptyPairMap<K, V> for the
non-empty {+ …} flavor, or BoundedPairMap<K, V, MIN, MAX> for every other window (each uses its
single checked conversion door). A tag-258 set
now defaults to reject (see Defaults below); every other set defaults to preserve and tables to
reject. Writing the effective default explicitly is accepted for self-documentation (a no-op). A
preserve table and a non-preserve map/table of the identical key/value coexist under --wasm:
the synthesized wasm class name encodes the backing container, so the preserve flavor is
PairMap<K>To<V> (and NonEmptyPairMap<K>To<V> for {+ …}) while the default flavor keeps
Map<K>To<V> — two distinct classes, each wrapping its own inner type. The K and V fragments
also retain nested collection bounds and map flavor recursively.
Selects the per-rule policy for how a collection handles duplicate entries on the CBOR wire. CBOR permits duplicate map keys and duplicate set entries; which stance a rule wants depends on the data (a validating writer rejects them; a multi-era reader must accept and re-emit historical duplicates byte-exactly).
strict_set = #6.258([* pool_id]) / [* pool_id] ; @duplicates reject
historical = { * metadatum => metadatum } ; @duplicates preserve
The directive takes one required argument:
| value | meaning |
|---|---|
preserve | accept duplicates on the wire and re-emit them byte-exactly (the contract is preservation, not merely "allow") |
reject | duplicates are a decode error (DeserializeFailure::DuplicateKey) and unconstructable through the generated API |
A missing or unknown argument is a hard error.
reject requires the element type to have a total order (the uniqueness door checks duplicates
with an Ord-based scan — linear for small sets, sorted-index O(n log n) for large ones, so a
large adversarial input cannot buy quadratic decode work). Every generated and standard element type
satisfies this automatically except floats: a float-containing element under reject (or in any
tag-258 set nominal, whose always-on comparison derives need Ord too) is rejected at generation
time with a clear error, mirroring the float-map-key rejection. Floats stay fully supported in
collections without the uniqueness requirement. @duplicates preserve changes a tag-258 nominal's
inner carrier but does not remove the nominal wrapper's comparison derives, so it cannot make a
float-containing tag-258 set valid; to keep float elements, rewrite that set as a plain array.
Defaults depend on the tag. Tag 258 is the IANA set tag, so a tag-258 set rule — the
#6.258([* a]) / [* a] idiom, a single-arm #6.258([* a]), or an inline #6.258 occurrence —
defaults to reject: the well-known-tag registry supplies set semantics (uniqueness) as the
default. Every other set/array rule (a non-258 tagged idiom, a plain [* a] / [+ a]) still
defaults to preserve, and tables still default to reject. Writing the effective default
explicitly is accepted as self-documentation (a no-op, no warning); the opposite value is the live
opt-out. In particular, @duplicates preserve on a tag-258 set is the opt-out back to the
occurrence-selected Vec<T> / NonEmptyVec<T> / BoundedVec<T, MIN, MAX> carrier, or native
[T; N] for an exact N*N homogeneous window (today's wire behavior verbatim), for a rule that
must accept and re-emit historical duplicate-bearing data.
A tag-258 set previously defaulted to preserve; it now defaults to reject. Generated code still
compiles, but a decoder built from a no-directive 258 set now fails with DuplicateKey on loose
historical bytes that carried duplicate elements. The migration is one line of CDDL you control: add
; @duplicates preserve to the rule. For an inline #6.258 occurrence that has no rule-level
comment slot, hoist it to a named rule to carry the directive
(input_set = #6.258([* input]) / [* input] ; @duplicates preserve, then reference input_set) —
CDDL rule references are transparent, so the hoist is semantically identical.
Under @duplicates reject a set rule's transparent alias targets an order-preserving,
duplicate-free set type: OrderedSet<T> for [* T], NonEmptyOrderedSet<T> for [+ T], or
BoundedOrderedSet<T, MIN, MAX> for every other bounded window, instead of the corresponding
Vec carrier. Its single TryFrom door is the only way to build one, so a duplicate is
refused identically whether it arrives on the wire or through the generated API — the decode error is
DeserializeFailure::DuplicateKey(Key::Uint(i)), where i is the zero-based index of the offending
element. The bounded carrier also refuses an out-of-window length at that same door and exposes only
checked mutation, so uniqueness and cardinality cannot drift apart. Rejection only narrows which inputs are accepted; an accepted (duplicate-free) value
re-emits byte-exactly in its original wire order (the set is never sorted).
Under @duplicates preserve a table rule's transparent alias targets an entry-ordered,
duplicate-permitting pair-map (PairMap<K, V>, or NonEmptyPairMap<K, V> for the {+} flavor)
instead of the loose BTreeMap/OrderedHashMap. A table rule that mints a type of its own — a
tagged body (#6.n({* k => v}), the optional-tag idiom included) or a @newtype rule — holds
the same pair-map twin as its wrapper's inner rather than through an alias, so the policy
composes with the tag/@newtype wrapper and the rule's own codec owns the tag (see
current capacities). A loose map keyed by key value is structurally
incapable of holding two entries with the same key, so preserve is the only way to accept AND re-emit
duplicate-keyed maps byte-exactly — the load-bearing property for Cardano transaction_metadata,
whose auxiliary-data hash is computed over the original bytes. The pair-map's read surface is
map-flavored but honest about duplicates: get returns the first match, get_all every match in
entry order, and insert appends (it never replaces an existing key). Under --canonical-form
the entries are stable-sorted by encoded key bytes (duplicates kept adjacent in first-appearance
order) — a deterministic best-effort, since duplicate-carrying data has no RFC 8949 canonical form.
In the JSON representation a PairMap is an array of [k, v] pairs, not an object (a JSON object
would silently collapse duplicate keys).
The policy is a per-rule knob (not a CLI flag) because mixed-era specs need both stances in one document — a Conway-strict set next to a historical preserve-mode set.
@duplicates on an inline table's row
An anonymous inline table written in type position — a union arm, a field type, an array element
type — carries the directive in its own row-entry slot: a trailing comment on the * k => v row,
inside the braces, with the closing brace on the next line.
tmd = { * transaction_metadatum => transaction_metadatum ; @duplicates preserve
} / [* transaction_metadatum] / int / bytes / text
preserve there swaps that inline table to the same PairMap<K, V> twin a named preserve table gets
(here the union's map variant holds a PairMap), and an explicit reject is the accepted default. The
slot is inline shapes only: a table written as its own rule and referenced by name takes its
policy from that rule, never from the reference site, so one shared type can never end up with two
duplicate stances depending on who embeds it.
On the component face, both a named and an
anonymous-inline table project as list<tuple<k, v>>. An explicit table reject is still the
infallible map default there; only array/set reject re-enters a fallible uniqueness door.
The named spelling remains available and is the better one whenever the same table is used twice:
metadata_map = { * metadatum => metadatum } ; @duplicates preserve, referenced from the union. It
works for a self-referential union too (md = md_map / [* md] / … with
md_map = { * md => md } ; @duplicates preserve, the transaction-metadatum shape) — in either rule
order, whether the cycle is written starting from the union or from the map.
Exactly one spelling per shape, in both directions. A NAMED table's row is not a second place to
write the policy: tbl = { * k => v ; @duplicates preserve is a loud rejection pointing at the
rule slot. The asymmetry is the point — an anonymous table has no rule slot, so its row is the only
place the policy can live; a named table has one, so a second honored spelling would only let the two
drift apart.
The row-entry slot of an anonymous inline table honors @duplicates and nothing else. Every other directive written there is a
loud rejection naming the spelling that works — a row entry declares no rule, field or type of its
own, so @name, @doc, @ignore, the @custom_serialize/@custom_deserialize pair and its
@custom_encodings/@custom_wire_major declarations have nothing to attach to. @name on a union's
map arm belongs after the closing brace, where it names the variant — and since a comment runs to
end of line, the next arm has to start on the next line:
tmd = { * uint => text } ; @name captured
/ int
A parenthesized row ({ * (k => v) }) has no entry slot of its own — write the row unparenthesized
to carry a directive on it.
The directive is otherwise rule-level scoped: on a type-choice rule (the tag-set idiom's two arms) it applies to the whole collapsed rule, and it must be written as a trailing comment after the last arm — a rule-level directive placed after an earlier arm is a loud misplacement rejection naming the directive and the last-arm remedy, never silently ignored.
It applies only to set/array collection rules and table rules. On any other placement — a text/int alias, a struct/group/record rule, a union rule, an extern marker, or a field/member position — it is a graceful placement rejection (never silently ignored).
@name and @duplicates on an open-map rest row
An open struct-map mixes fixed members with a trailing * K => V "rest row" that captures every
unknown map entry ({ 1: uint, 2: text, * uint => any }) — see
Open struct-maps for the generated rest field and its
surfaces. Two directives read from the rest row's own entry-trailing comment slot:
metadata = {
1: uint,
* uint => any ; @name extra @duplicates preserve
}
@namerenames the generated capture field (defaultrest) — here toextra. It also names the wasm parent-mutation door (insert_extra(key, value)rather thaninsert_rest); the containing type keeps its rule name. A field whose wasm getter would collide with that generated method is refused under--wasmwith an@nameremedy rather than silently suffixing public API.@duplicatesselects the rest row's duplicate-key policy, exactly as for a standalone table: the default isreject(a repeated captured key is aDuplicateKeydecode error), and@duplicates preserveswitches the capture container to the byte-exactPairMap<K, V>twin so duplicate captured keys round-trip verbatim.
The policy is orthogonal to the key domain: K may be any type the row accepts (see
Open struct-maps for the domain rules), and the two
compose as they do on a table — a default/reject row's container demands the full key bundle of K,
while a preserve row's PairMap compares keys by scan and so demands only the
@used_as_key ord family. The row places that demand for you, including across the
workspace seam: when K belongs to a dependency crate, the row contributes a row to this crate's
borrowed_key_types.rs
sidecar — in the flavor its own container needs — exactly as a declared map field would.
The directives must trail the whole entry (after the V type), not the * occurrence marker's
own comment slot. A directive glued to the marker —
metadata = {
1: uint,
* ; @name extra
uint => any
}
— is silently not honored (the field stays rest): cddl-codegen ignores the occurrence marker's
comment slot everywhere, so the rest-row reads only the entry-trailing slot.
A rule-position directive (on the same line as the closing }) is read at rule level, not as the
rest row's — the two slots are disjoint, so neither steals from the other.
The same entry-trailing slot serves an open-array occurrence segment — a final tail
([uint, tstr, * bytes]) or a safe middle segment ([uint, * bytes, tstr]; see
Open arrays) — with one asymmetry:
@namerenames the captured segment field (defaultrest) exactly as it renames the map capture field —* bytes ; @name extrasyieldspub extras: Vec<Vec<u8>>; the non-empty spelling+ bytes ; @name extrasyieldspub extras: NonEmptyVec<Vec<u8>>and its generated constructor'sfirst_extras_elementargument; a bounded spelling such as2*3 bytes ; @name extrasyieldspub extras: BoundedVec<Vec<u8>, 2, 3>and takes that complete checked carrier in its generated constructor; an ordinary exact2*2 bytes ; @name extrasyieldspub extras: [Vec<u8>; 2]instead. Exact@duplicates rejectcollections remain their ordered-set carrier because an array alone cannot enforce uniqueness.@duplicatesdoes not apply to an occurrence segment and is rejected gracefully: an array segment is a positional sequence carrier in every supported occurrence flavor, so there are no keys for a duplicate policy to govern.
The marker-slot trap above applies identically — the directive must trail the whole entry
(* bytes ; @name extras), never the * marker's own comment slot.
@ignore
@ignore on an open struct-map rest row or an open-array occurrence segment selects the
tolerate-and-drop flavor: unknown trailing data is still typed-deserialized (for a map, key and
value; for an array, each repeated element — so the stream advances past nested containers) but then
discarded — no capture field is generated, and serialize re-emits only the declared members.
view = {
1: uint,
* uint => any ; @ignore
}
view_list = [
uint,
* any ; @ignore
]
Use it for view types that must accept forward-compatible data without retaining it. The default
(no @ignore) is capture (map rest rows,
array rest tails), which keeps the generated
Serialize/Deserialize honest (nothing dropped, byte round-trips hold).
- Placement.
@ignoreis bare (no argument) and reads from the occurrence entry's own comment slot, exactly like@name/@duplicates(above). It is valid only on a recognized loose rest row of a map-rep record or a loose final/safe-middle segment of an array-rep record. A safe middle segment needs its immediate fixed suffix to be mandatory, single-item, field-codec-free, and CBOR-major-disjoint from the repeated element, with no custom- or extern-owned, otherwise-unproven boundary wire head; this is what permits greedy RFC 8610 decoding without a rewind. Anywhere else (a rule, a field, a same-major or otherwise unsafe non-final occurrence, or a restricted row/segment) is a loud graceful rejection, never silently dropped. The same marker-slot trap applies: a directive glued to the*occurrence marker's comment slot is silently not honored, so keep it after the whole entry (* K => V ; @ignore,* t ; @ignore). - The end-of-line trap. A CDDL comment runs to the end of the line, so a directive on the same
line as the container's closing token swallows that token:
; @ignore }eats the map's closing brace and; @ignore ]eats the array's closing bracket. Put the directive on its own line before the closer, as in both examples above — never[ uint, * any ; @ignore ]on one line. - Deliberate lossiness. Byte round-trips do not hold for wire data carrying unknown entries or
ignored final/safe-middle elements — they are dropped, and re-serialization emits the declared
members only.
The generated type and its
serializefn carry a rustdoc breadcrumb saying so. - Rejected combinations, each a graceful generation error naming the remedy:
- A restricted map rest row or open-array segment (
+,1*, or another bounded window): dropping its captured content re-serializes zero occurrences. That violates a positive minimum; a zero-minimum restricted window instead loses the bounded or exact state its checked carrier retains. Use capture so the matchingNonEmpty/Boundedcarrier retains the entries or elements. --preserve-encodings(and--canonical-form, which implies it): a preserve crate's contract is byte-exact round-trips, which a silently-lossy type would undermine crate-wide. Use capture (drop the directive), or@custom_serialize/@custom_deserializefor a genuine view type.@duplicateson the same entry: a duplicates policy governs a capture container, which@ignoredoes not create. (On an array occurrence segment@duplicatesis rejected for both flavors — a positional sequence has no keys for a duplicate policy to govern.)@nameon the same entry: there is no capture field to rename.
- A restricted map rest row or open-array segment (
- Typing is still enforced.
* uint => any ; @ignorestill errors on a text key (the spec said uint labels), and an ignored* textsegment still errors on a non-text repeated element; full looseness is spelled* any => any/* any. For a map, a declared key wins over the rest row, so a fixed key whose wire value mismatches its type errors rather than falling through to the drop arm.
The JSON and wasm surfaces of an @ignore type are a plain closed struct's (unknown JSON
keys / final or safe-middle array elements are tolerated on read and dropped; there is no rest() wasm getter,
since nothing is stored).
@custom_json
foo = uint ; @newtype @custom_json
Avoids generating and/or deriving json-related traits under the assumption that the user will supply their own implementation to be used in the generated library.
The generated crate does not compile until you write them. That is the trade the directive
makes, not an oversight: the tool stops deriving the JSON traits but keeps emitting every JSON
surface over the type, because those surfaces — a containing type's own derives, the wasm wrapper's
to_json/to_json_value/from_json, the --component guest's
to-json/from-json members, and the --json-schema-export registration row — are the
whole point of owning the JSON form by hand. Until your impls exist, each of them is an E0277
naming the trait and your type: serde::Serialize and serde::Deserialize under
--json-serde-derives (which is the flag the wasm and component doors ride too), plus
schemars::JsonSchema under --json-schema-export. Those three are
the complete list; the two sections below cover the serde pair and the schema impl in turn.
It works on any rule shape that mints a type of its own — newtypes, record structs (map/array group rules), and sum types. On a type-choice rule, rule-level directives attach via the trailing comment of the LAST variant:
my_sum =
uint ; @name integer
/ bytes ; @name raw @custom_json
The last arm's trailing comment is the rule-position slot — that is the whole of the mechanism,
not a convenience. A type-choice VARIANT position consumes only @name (which names the variant)
and @doc (which documents it), so a rule-level directive written on any other arm is a hard
error: generation stops and names the directive, the rule, and the remedy. Keep the directive on
whichever arm is last, and if you reorder the arms, move it with them.
That rejection exists because the alternative was worse than a wrong build. Before it, a reorder
turned the directive off in silence — the type quietly got its JSON derives back and they collided
with the impls you wrote by hand, in a compile error nowhere near the spec edit that caused it. And
the slot is shared: every rule-level directive reads from that same last-arm comment, so one
reorder could switch off several unrelated contracts at once. A single arm annotated
; @name bytes @custom_json @used_as_key carries both a JSON-suppression contract and a key-demand
contract, and a reorder dropped both — the JSON half silently, the key half as a missing derive that
generated/key_demand_assertions.rs at least names.
Incremental extension chains
A type choice written incrementally — one /= statement per arm, the socket/plug idiom described in
Incremental type-choice extension — is combined into a single
type-choice rule before any directive is read, so everything above applies unchanged, to the
combined arm list:
- the rule-position slot is the last arm of the last statement — the final line of the chain;
- every arm keeps its own
@name/@doc, naming and documenting the variant it becomes, wherever in the chain it is written; - a rule-level directive on any earlier statement is the same hard error as on any other non-last arm, with the same remedy: move it to the end of the chain.
$currency /= "usd" ; @name dollars
$currency /= "eur" ; @name euros
$currency /= uint ; @name code @custom_json
The consequence worth internalising is that adding a statement to the chain moves the slot.
Appending a fourth $currency /= … line puts the rule position on that line, and the
@custom_json above becomes a directive on a non-last arm — caught loudly, not silently dropped,
but it is your edit that has to move it.
The suppression covers serde::Serialize/serde::Deserialize and schemars::JsonSchema
together — one decision, not two — so --json-schema-export never leaves a JsonSchema derive
behind on a type whose JSON form you own. With --preserve-encodings, the #[serde(skip)]
attributes on the type's encoding fields are omitted along with the derives (either alone would not
compile).
Rules that cannot carry it: the transparent-alias family
A rule that lowers to a transparent alias — pub type Foo = u64; — is refused, with generation
stopping and naming the rule and the remedy. The refusal fires regardless of the JSON flags, because
where a directive may sit is a property of the spec, not of the build profile. The family is:
- a plain alias rule (
foo = uint ; @custom_json, and thefoo = bartypename form); - a
T / nullrule, which collapses to anOption<T>alias rather than an enum; - a table rule (
t = { * k => v }) and a named array rule (al = [* uint]) — these do mint a struct for the wasm wrapper, but the rust rule is still the transparent alias.
There is nothing there for the directive to do: an alias has no attribute site for the derives to be
suppressed on, and it is not a nominal type, so the orphan rule forbids the hand-written
Serialize/JsonSchema impls the directive promises. Add @newtype — foo = uint ; @newtype @custom_json, al = [* uint] ; @newtype @custom_json — and the rule mints a real wrapper struct
that carries both.
On a _CDDL_CODEGEN_EXTERN_TYPE_ or _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule (including a generic extern
base) the directive is refused. The named type is hand-written in full, so this crate emits no JSON
derives to suppress and the impls the directive promises belong beside that type, not here. Give the
rule a real CDDL body and put @custom_json there, or drop it and write the impls next to the
externally-defined type.
A named binding to a generic set nominal (foo = gset<uint>, where
gset<T> = #6.258([* T]) / [* T]) is refused too, and for a different reason: the binding emits a
transparent pub type Foo = GsetU64; and mints no type of its own, while the derives it would
suppress belong to GsetU64, whose config comes from the generic definition. The @newtype
remedy does not apply here — a set nominal already is a wrapper. Put @custom_json on the definition
instead (gset<T> = #6.258([* T]) / [* T] ; @custom_json).
Two rule kinds mint a struct from metadata that is not their own, and do honor the directive: a
generic instance binding (foo = base<uint>, whose struct config comes from the definition) and a
plain group spliced into a rule that materializes it (foo = (a: uint, b: uint) ; @custom_json
with holder = [foo]). Both suppress the derives on the struct the rule mints.
Writing the Serialize impl the directive promises
The impl you supply must be honest in the serde data model, not merely produce the right JSON
string — the wasm to_json_value() hands your type to serde-wasm-bindgen, not to serde_json.
The trap is that the most natural way to write one of these impls (build a serde_json::Value, hand
it to the serializer) is dishonest whenever the serde_json/arbitrary_precision cargo feature is on
anywhere in your build graph: every number then serializes as a private token struct that only
serde_json's own serializer collapses. Generated crates ship a json_value_ser runtime module for
exactly this, and the full explanation and recipe are in
Wasm differences § A hand-written Serialize must be honest in the serde data model.
Writing the JsonSchema impl the directive promises
Under --json-schema-export the impl you supply is not optional decoration: the json-gen crate
checks, before writing anything, that every $ref in the finished document resolves inside that
same document (see Command line flags § --json-schema-export). A
hand-written impl that returns a bare Schema::new_ref("SomeType") where a body belongs fails that
check by name. So json_schema() has to return the real body.
Author that body as a JSON file and let the custom_schema_impl! macro write the impl around it:
// rust/src/plutus.rs — a hand-owned module of the generated rust crate
crate::custom_schema_impl!(PlutusData, "custom_schemas/PlutusData.json");
…where rust/src/custom_schemas/PlutusData.json is the body itself:
{ "type": "object", "properties": { "constr": { "$ref": "#/$defs/ConstrPlutusData" } } }
That is the whole impl: schema_name() (derived from the type token), json_schema() (the file,
with its references pointed at the document's real definitions namespace) and inline_schema()
returning false.
Where the invocation may be written. Each of these is a compile error to get wrong, so the list is short by necessity:
- It is a crate-root macro of the crate hosting the
json_schema_genmodule. In-crate that is the generated rust crate itself, socrate::custom_schema_impl!(…). Under--common-import-override/--export-static-cratethe module lives in your common crate, so it is<common>::custom_schema_impl!(…)— never<common>::json_schema_gen::custom_schema_impl!, since#[macro_export]hoists it to that crate's root. - That hosting crate must have
json_schema_genreachable from its root, because the expansion reaches back into it. Under--export-static-cratethat is thepub mod json_schema_gen;you hand-declare (the tool's new-file notice names it). In-crate the tool declares the module insidesrc/generated/mod.rs, so root reachability comes from the seed-oncesrc/lib.rs'spub use generated::*;— a line you own after the first export. Narrowing that glob to a name list makes every invocation anE0433forjson_schema_gen, reported at the macro rather than at the edit that caused it. - The invocation must live in the crate that DEFINES the type, because
schemars::JsonSchemais a foreign trait and the orphan rule allows the impl nowhere else. For a generated type carrying@custom_jsonthat means a hand-owned module of the generated rust crate, declared from its seed-oncesrc/lib.rs— outsidesrc/generated/**, which every run clobbers. Under an override this is the one place the macro's crate and the invocation's crate differ, which is fine. include_str!resolves the path relative to the INVOKING file, not to the macro's. Keep the JSON inside the invoking crate's own directory: a published crate ships only files under its own directory, so a path reaching into a sibling crate compiles locally and breaks atcargo publish.- The invoking crate needs
schemarsreachable under that name, and only that — the document is parsed inside the hosting crate, so the invocation site needs no JSON dependency of its own. Under--json-schema-exportthe generated rust crate already declaresschemars.
Write internal references as #/$defs/<Name> — that is the authoring convention, and the macro
retargets them onto whatever namespace the generator actually uses before handing the body back, so
the string is not a runtime fact encoded by hand. It matters because add_schemas takes the
generator as a parameter: a consumer composing several crates' rows can supply one whose definitions
live elsewhere. References the convention does not cover are left exactly as written — a bare
"PlutusData", an http(s):// URL, a pointer into another document — because those are precisely
what the closure check exists to report, and rewriting one would turn a named failure into a
differently-dangling reference.
inline_schema() returning false is what makes the type referable: schemars then registers the
body as a definition and hands out a $ref to it, so one hand-authored schema can point at
another's entry ({"$ref": "#/$defs/ConstrPlutusData"}) and each shape is declared once. What that
does not do is create the entry — a type reached only through a hand-written $ref is never
visited by schemars at all, so it needs a registration row of its own (its own CDDL rule, or
--json-schema-root) or the closure check fails on the dangling pointer.
Stating the published name. The two-argument form derives it from the type token, so a hand-authored type's published name follows the same rule as every generated sibling in the same document by construction. A three-argument form takes it as an expression instead, for the two shapes that derivation cannot serve — a type that is not a bare identifier at the invocation, and a deliberate published name that differs from the Rust one:
crate::custom_schema_impl!(Ext<u64>, "custom_schemas/ExtU64.json", "ExtU64");
An expression rather than a string literal, because schema_name() doubles as schema_id() by
default and therefore as an identity: a generic whose instantiations all report one constant name
is the silent merge the injectivity guard panics on, and you are the only party who can vary it
(format!("Base_{}", <T as schemars::JsonSchema>::schema_name())).
Writing the impl by hand
Two shapes the macro cannot serve, both of which want a hand-written impl block:
- the body is not a static file — it is computed, assembled from several files, or varies with a type parameter;
- the impl needs a member the macro does not write — a
schema_id()stated separately fromschema_name(), or aninline_schema()oftrue(which makes the type un-referable, so no other hand-authored file can point at it).
Write the impl yourself and call custom_schema_body(generator, origin, source) for the body — the
same function the macro's json_schema() member calls, so you keep the reference retarget and the
two file-naming failure messages without reimplementing either:
impl schemars::JsonSchema for PlutusData {
fn schema_name() -> std::borrow::Cow<'static, str> { "PlutusData".into() }
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
crate::json_schema_gen::custom_schema_body(
generator,
"PlutusData.json",
include_str!("custom_schemas/PlutusData.json"),
)
}
fn inline_schema() -> bool { false }
}
origin is the file's name as it appears in those two messages (… is not valid JSON, … is not a valid JSON schema) and is otherwise unused — it is the author's only handle on WHICH hand-authored
file broke, since the impl has no other identity at run time. The module path here is the ordinary
one (crate::json_schema_gen::…, or <common>::json_schema_gen::… under an override); the
crate-root spelling in fact 1 applies to the macro alone.
Worth adding on your side: a round-trip test validating serde_json::to_value of real values
against the same files, composed the way the document composes them
({"$defs": {…every file…}, "$ref": "#/$defs/<Name>"}). Nothing in this tool can check that a
hand-authored schema describes the type's actual serialization — that is the whole meaning of
@custom_json — so the assertion has to live where the impls do.
@no_json_schema_export
plutus_map = _CDDL_CODEGEN_EXTERN_TYPE_ ; @no_json_schema_export
constr_plutus_data = _CDDL_CODEGEN_EXTERN_TYPE_ ; @no_json_schema_export
Declares that a type is not a published JSON-schema root. Its only effect: under
--json-schema-export the json-gen crate's add_schemas emits no registration row for the type, so
the type is not one of the crate's declared schema roots.
A root, not a definition — this is the part to read twice. The crate emits one schema document
whose $defs holds every type reached from a registration row, so dropping a type's row removes it
from the declared roots and nothing else. A type you suppress that a published type still
references stays in $defs, and therefore still appears in the emitted TypeScript. The only way
to keep a type out of the surface entirely is to stop published types from referencing it — which is
@custom_json on the parent, the same pairing the sharp edge below describes. This
is not a policy choice: a reference that survives into the document has to resolve, or the shipped
.d.ts names a type nothing declares.
A suppressed type also still claims its schema_name() in the document — at whatever point a
published type first reaches it. If a later registration row publishes the same name, that row is the
one the json-gen run rejects (… but the document assigned it "<name>2" …), so a suppressed type's
name must be unique within the crate exactly like a published one's.
This exists because a type having a derivable JSON schema is not evidence that the derived shape
is that type's published encoding. A serde/schemars derive can exist as an artifact while the
real encoding is produced by a parent type's hand-written impl; a JsonSchema impl can be a
deliberate stub; and a hand-written _CDDL_CODEGEN_EXTERN_TYPE_ may have no schemars::JsonSchema
impl at all — in which case its row is an E0277 inside a generated file. The tool cannot tell
those apart from a genuine schema root. The spec author can, so this is the spec author's say-so.
The boundary is deliberately narrow. @no_json_schema_export does not:
- remove the
serde::Serialize/serde::Deserializeorschemars::JsonSchemaderives on a generated type. A parent that embeds the type still needsJsonSchemaon it (and that reference is what keeps the type in$defs, per above). Removing the derives is@custom_json, which is orthogonal — combining the two on one rule is legal and meaningful ("I supply the JSON impls, and this type is not a published schema root"); - change CBOR serialization, the wasm surface, the extern-interface export, or the extern self-check;
- do anything at all when
--json-schema-exportis off. The directive is then simply inert, so one spec can be generated under several flag sets without editing.
The opposite direction — adding a row for a published type the CDDL never describes — is the
--json-schema-root flag, which takes a Rust type path rather than a rule
name. It consults no IR, so naming a type whose rule carries this directive re-registers it. The
three ways to adjust the document's contents, and which to reach for, are listed under
Command line flags § --json-schema-export.
An extern with no JsonSchema impl also needs @custom_json on the types that embed it
This is the one sharp edge, and the error it produces does not point at it. @no_json_schema_export
removes the extern's own row, but a generated type that embeds the extern still derives
schemars::JsonSchema over that field — so the missing impl resurfaces as an E0277 on the
parent, in a different file, naming a type you did not annotate:
quiet_ext = _CDDL_CODEGEN_EXTERN_TYPE_ ; @no_json_schema_export
plain_parent = [n: quiet_ext] ; E0277 — derives JsonSchema over quiet_ext
guarded_parent = [n: quiet_ext] ; @custom_json ; compiles — derives nothing JSON-related
The rule: @no_json_schema_export on the extern, plus @custom_json on every generated type that
embeds it (and hand-written JSON impls for those parents, which is what @custom_json promises).
That pairing is the normal shape for this case anyway — a type whose published JSON is produced by a
hand-written parent impl is exactly the situation the directive exists for.
If instead the extern does implement schemars::JsonSchema and you only want it off the published
surface, no parent annotation is needed.
Two embedding positions are exempt, because their schema never asks the field type for one, and both
are the same position for the same reason: a map row's KEY domain. An open struct-map rest
row's key, and both rows' keys in an open
table. A row key is a JSON object member
name, so the flattened region publishes an open object over the RANGE(s) — a shape that mentions the
key type nowhere. holder = { 1: uint, * quiet_ext => uint } and
tbl = { * quiet_ext => uint, * md => md } therefore compile with no @custom_json on them, and
quiet_ext stays absent from the published document. The RANGES are not exempt — an open region names
its value type, so a @no_json_schema_export value still needs the @custom_json pairing above.
It is valid on any rule that produces a rust type — an extern, a record, a sum type, a @newtype
wrapper, a collection typedef, a generic instantiation, and a plain group that some rule splices:
grp = (a: uint, b: uint) ; @no_json_schema_export
On a rule that registers no rust type there is no row to suppress, so the directive would be
silently dead and generation is rejected with an error naming the rule. That covers a plain
transparent alias (foo = uint), a @no_alias alias, a named binding to a generic instantiation, a
plain group nothing splices, and a generic definition — only its instantiations are types, and
they do not inherit the directive, so annotate the instance:
gset<T> = [* T]
inst = gset<uint> ; @no_json_schema_export ; correct — the instance is the type
On a plain group, a rule-position directive has to end the line the closing paren is on. Two spellings do that — the whole group on one line, or the closing paren kept on the last entry's line:
grp = (a: uint, b: uint) ; @no_json_schema_export
grp = (a: uint,
b: uint) ; @no_json_schema_export ; also fine — the paren ends the last entry's line
A closing paren on its own line is rejected, with an error naming the rule, the directive(s) found and both spellings above:
grp = (
a: uint
) ; @no_json_schema_export ; refused — the parser cannot bind a directive here
The pinned CDDL parser binds a group rule's rule-position directive to its last group entry, and a
paren on its own line leaves the trailing comment past that slot — it is merged into the following
rule's leading-comment slot instead (or orphaned when the group rule is last), a position nothing
reads. Since the directive cannot be delivered at all in that position, generation refuses the
spelling rather than dropping it. A prose comment there is fine; only a directive is refused.
(There is no third lossy spelling: a comment runs to end of line, so putting a field-position comment
before the closing paren on one line comments the paren out and the spec does not parse at all — a
loud failure, not a drop.) Adopting a parser-side fix would make the refused spelling honored
instead; tests/testing-roadmap.toml ("Adopt the parser's RuleTrailing anchor and classify that
rule-only slot in one delivery — blocked on publishing the reviewed fork revision.") tracks that.
A suppressed type still has a wasm to_json_value(), so the JSON → TypeScript scripts fail on it
The "does not change the wasm surface" bullet above is exact, and its consequence is worth spelling
out because the failure it produces surfaces two build steps later. Under --wasm --json-serde-derives, a rule that mints a generated wasm class — a record, a type or group
choice, a @newtype wrapper — declares to_json_value(): any in the wasm-pack .d.ts whether or not
it carries this directive: that method is emitted off the derives, not off the schema row. (Rules
that mint no class of their own are untouched by this: an _CDDL_CODEGEN_EXTERN_TYPE_ — the examples
opening this section — has a hand-written wasm face, and a collection wrapper or a c-style enum
declares no JSON method at all.)
That only bites when the type is also absent from the document's $defs, which takes the recipe
in Command line flags § --json-schema-export: @custom_json on every rule
that would reference it. A suppressed type that something published still references stays in $defs
and its class is typed as usual. But suppressed and unreferenced is the directive's primary use
case — a type whose real published JSON comes from a parent's hand-written impl — and there
json-ts-types.js finds a class declaring to_json_value(): any with no <Class>JSON to splice in,
and fails the run naming it — see
Command line flags § --json-schema-scripts.
The remedy is --allow-untyped=<Class> on that script. Spelling it per class is what makes the
duplication informative rather than noise: it is checked in both directions, so if the type later
becomes reachable from a published root it is typed, and the stale-entry error tells you to drop
the exception.
@custom_serialize / @custom_deserialize
custom_bytes = bytes ; @custom_serialize custom_serialize_bytes @custom_deserialize custom_deserialize_bytes
struct_with_custom_serialization = [
custom_bytes,
field: bytes, ; @custom_serialize custom_serialize_bytes @custom_deserialize custom_deserialize_bytes
overridden: custom_bytes, ; @custom_serialize write_hex_string @custom_deserialize read_hex_string
tagged1: #6.9(custom_bytes),
tagged2: #6.9(uint), ; @custom_serialize write_tagged_uint_str @custom_deserialize read_tagged_uint_str
]
This allows the overriding of serialization and/or deserialization for when a specific format must be maintained. This works even with primitives where CDDL_CODEGEN_EXTERN_TYPE would require making a wrapper type to use.
The string after @custom_serialize/@custom_deserialize will be directly called as a function in place of regular serialization/deserialization code. As such it must either be specified using fully qualified paths e.g. @custom_serialize crate::utils::custom_serialize_function, or post-generation it will need to be imported into the serialization code by hand e.g. adding use crate::utils::custom_serialize_function;. Prefer the fully qualified spelling for anything you regenerate: the hand import lands in the tool-owned src/generated/** tree, which every regeneration clobbers, so a bare name means re-adding the import each run — or making it durable as a cddl-codegen:insert preserved block, which survives regeneration; the qualified path needs no import at all.
With --preserve-encodings=true the encoding variables must be passed in in the order they are used in cddl-codegen with regular serialization. They are passed in as Option<cbor_event::Sz> for integers/tags, LenEncoding for lengths and StringEncoding for text/bytes. These are the same types as are stored in the *Encoding structs generated. The same must be returned for deserialization. When there are no encoding variables the deserialized value should be directly returned, and if not a tuple with the value and its encoding variables should be returned. The pair also inherits the profile's fidelity contract: your reader must reject any wire form it cannot re-emit from the decoded value plus the encoding variables it returns, because those variables (widths, chunking, indefiniteness) are the only non-value wire facts the sidecars can carry for you. A reader more lenient than its writer — accepting uppercase hex and re-encoding it lowercase, or parsing +7/07 as decimal text and re-emitting 7 — silently turns an accepted document into different bytes, which is exactly what the profile promises never happens; reject such input instead of normalizing it. When the custom wire's framing is not the replaced type's — most sharply when the replaced type carries its own encodings and so demands none — declare it instead: see Declaring the wire's encoding variables below.
There are two ways to attach this comment DSL:
- Type level: e.g.
custom_bytes. This will replace the (de)serialization everywhere you use this type. - Field level: e.g.
struct_with_custom_serialization.field. This will entirely replace the (de)serialization logic for the entire field, including other encoding operations like tags,.cbor, etc. Both halves must appear on the entry: a field's two directions are lifted independently, so a lone half is refused rather than shipped as a field that writes bytes it cannot read back.
A named record rule honors the pair only when both halves appear on the rule. The generated
record keeps its constructor/accessor API and receives thin Serialize and Deserialize impls
that delegate to the named writer and reader with self / raw; a field that names the record
dispatches to that same pair before record-kind handling. The pair therefore owns one complete CBOR
item through both direct (to_cbor_bytes / from_cbor_bytes) and embedded doors. Record encoding
metadata remains self-carrying, so this whole-record spelling takes no external encoding tuple.
custom_record = [ value: uint ] ; @custom_serialize write_custom_record @custom_deserialize read_custom_record
holder = [ nested: custom_record ]
The writer may intentionally use a wire form unlike [uint] (for example a text item), provided
the reader accepts exactly that complete form. This spelling is supported for array- and map-rep
records, including a concrete record minted from a generic record definition; its definition's
pair becomes the concrete type's pair. A comment on the separate generic-instance binding still
has no config slot and is rejected. A plain group's field slot likewise remains field metadata and
follows the ordinary field splicing rules. Because the reader owns the whole item, it also
supersedes generated-only record decoder refusals such as an optional array member adjacent to an
open rest tail; its
Deserialize/from-cbor-bytes surface remains available across the Rust, wasm, and component
faces.
Example function signatures for --preserve-encodings=false for custom_serialize_bytes / custom_deserialize_bytes above:
pub fn custom_serialize_bytes<'se>(
serializer: &'se mut cbor_event::se::Serializer,
bytes: &[u8],
) -> cbor_event::Result<&'se mut cbor_event::se::Serializer>
pub fn custom_deserialize_bytes(
raw: &mut cbor_event::de::Deserializer,
) -> Result<Vec<u8>, DeserializeError>
Example function signatures for --preserve-encodings=true for write_tagged_uint_str / read_tagged_uint_str above:
pub fn write_tagged_uint_str<'se>(
serializer: &'se mut cbor_event::se::Serializer,
uint: &u64,
tag_encoding: Option<cbor_event::Sz>,
text_encoding: Option<cbor_event::Sz>,
) -> cbor_event::Result<&'se mut cbor_event::se::Serializer>
pub fn read_tagged_uint_str(
raw: &mut cbor_event::de::Deserializer,
) -> Result<(u64, Option<cbor_event::Sz>, Option<cbor_event::Sz>), DeserializeError>
Note that as this is at the field-level it must handle the tag as well as the uint.
With --canonical-form=true every serialize function takes one further trailing argument, force_canonical: bool, which must be passed on to the encoding helpers it calls (StringEncoding::to_str_len_sz(len, force_canonical), fit_sz(len, sz, force_canonical), …) so that the canonical output re-minimizes the same way generated code does. The deserialize signature is unchanged.
Declaring the wire's encoding variables (@custom_encodings)
The paragraph above derives the encoding variables a codec is handed from the replaced type. That only produces the right list when the custom wire's framing happens to match the replaced type's own, slot for slot — and when the replaced type is self-carrying (a _CDDL_CODEGEN_EXTERN_TYPE_, a named record, bool, any, a null-fixed) it demands none at all, so the custom wire's framing would be recorded nowhere and the round trip would silently normalize it.
@custom_encodings lets the pair declare what its own wire needs, and the declaration then drives the signatures and the *Encoding slots everywhere, instead of inference:
an = _CDDL_CODEGEN_EXTERN_TYPE_
an_v1 = an ; @custom_serialize write_asset_utf8 @custom_deserialize read_asset_utf8 @custom_encodings str
The argument is a comma-separated list of kinds with no whitespace, or the keyword none:
| kind | rust type | what it records |
|---|---|---|
sz | Option<cbor_event::Sz> | how an integer — or a tag head — was sized |
str | StringEncoding | how a text/bytes header was written (definite width, or the indefinite chunk lengths) |
len | LenEncoding | how a container's length header was written |
none | — | the explicit empty list: this wire has no framing |
The list is the codec-visible tuple, positionally: serialize receives the declared variables as trailing arguments in declared order (before force_canonical), and deserialize returns (value, declared…) in the same order. So the pair above is written:
pub fn write_asset_utf8<'se>(
serializer: &'se mut cbor_event::se::Serializer,
asset: &An,
name_encoding: &StringEncoding,
) -> cbor_event::Result<&'se mut cbor_event::se::Serializer>
pub fn read_asset_utf8(
raw: &mut cbor_event::de::Deserializer,
) -> Result<(An, StringEncoding), DeserializeError>
Rules to keep in mind:
- It requires both halves, in the same position. A declaration beside one half (or none) is a graceful rejection: the other direction would be generated code deriving the replaced type's inferred demand, which the declared slots contradict slot for slot.
- It binds to the pair written beside it. A field-level pair overrides a type-level one, and each pair's declaration travels with it — a field-level pair without its own declaration gets inference over the field's type, never the shadowed alias's declaration, because that one describes a different codec's wire.
- The declaration wins wherever it is present, including over a non-empty inference: an alias of
byteswhose codec writes#6.42(text)declaressz,strand gets exactly those two slots. - Argument mode stays position-derived.
szandlenareCopyand passed by value;stris passed by reference at a record field and by value at a table entry, exactly as the two positions rule already states. The declaration fixes the count and the types, never the mode. - Some slots are not yours to declare. A member's
<field>_default_presentflag and a map-record member's<field>_key_encodingare generated-code-owned and are never part of the codec's tuple; the declaration describes only what crosses the call. - Without
--preserve-encodingsit is inert — no encoding variable exists to declare — so one spec can serve both flag sets.
Stated limit: the aggregate kinds (the per-element Vec<…> / BTreeMap<…> sidecars a container's interior fidelity needs) and TagPresenceEncoding are not declarable, and the parser names the supported kinds when it rejects an unknown one. If your custom wire is a container whose per-entry encodings inference cannot supply, say so — that is the observation that would make us add them.
Because the declaration makes the framing-carrying spelling available, the un-declarable state is now refused rather than silently normalized: under --preserve-encodings, a pair over a type that demands no encoding variables and that carries no declaration is a graceful rejection. Write @custom_encodings <kinds> for a wire with framing, @custom_encodings none for one without, or — if the replaced type has one wire and needs no override at all — drop the pair and let the type's own impls own it. Without --preserve-encodings the same spec generates exactly as before.
Declaring the wire's major type (@custom_wire_major)
@custom_encodings declares what a codec's wire records; @custom_wire_major declares what it starts with — which of the eight CBOR major types its first data item is. It is the second member of the same wire-facts family, and it exists for the same reason: the pair owns the wire, so the generator's inference over the type the codec replaced answers about a wire nobody writes.
Two readers consume it: an open table's typed row, whose dispatch must know the claimed major before any deserializer runs; and a variable middle open array occurrence boundary, whose greedy loop must know whether the next item is another repeat or its fixed suffix.
policy_id = _CDDL_CODEGEN_RAW_BYTES_TYPE_
; the v1 wire writes the same value as lowercase hex TEXT
policy_id_v1 = policy_id ; @custom_serialize write_hex @custom_deserialize read_hex @custom_wire_major text
labels_v1 = { * policy_id_v1 => details, * metadatum => metadatum }
Without the declaration the generator would read bytes off the raw-bytes marker and dispatch major 2, so every policy_id_v1 key on the wire — text, major 3 — would fall through to the catch-all. That is the silent wrong answer the directive removes, which is why its absence at that position is a graceful rejection rather than a guess.
The same wire fact can prove a major-disjoint variable middle boundary. Here elem replaces uint
but its codec writes CBOR bytes, so the repeated loop peeks bytes and stops greedily at the text
suffix; it never calls the suffix reader speculatively:
elem = uint ; @custom_serialize write_elem @custom_deserialize read_elem @custom_wire_major bytes
m = [uint, * elem, tstr]
It also applies on the suffix side (m = [uint, * bytes, suffix] where suffix declares text),
but there it proves only disjointness: after the loop stops, the normal suffix codec reads the item.
The argument is exactly one of the eight major-type tokens: uint, nint, bytes, text, array, map, tag, simple. An unknown token is a loud parse failure naming the set.
Rules to keep in mind:
- It requires both halves of the pair, in the same position — the
@custom_encodingscontract verbatim, and for the same reason. - It is REQUIRED where a custom codec keys an open table's typed row or owns either boundary of a variable middle open-array occurrence, and a graceful rejection where nothing consumes it. Consumed somewhere is enough, so one alias may key an open table, prove a middle boundary, and also appear at an ordinary field.
- It is not a general framing escape hatch. Final tails and exact
N*Nmiddle windows do not need a major peek; mandatory generated outer tag/.cborframing wins without reading an inner declaration; and optional-prefix lookahead remains generator-proven-only. An opaque/custom head there is still serialize-only unless mandatory generated outer framing proves it. A field-local declaration remains refused because only a transparent alias carries this wire fact to either reader. - It is refused on a rule that mints a struct. The declared major is read through the rule's transparent alias entry, which a struct-minting rule does not have.
- A tagged typed key claims ALL tags.
@custom_wire_major tag(like a plain tagged key type) claims major 6 in full — the dispatch is by major, not by tag number. - Unlike
@custom_encodingsit is not preserve-gated. The major is a fact about the wire under every flag set.
Open tables
A named rule of exactly two * k => v rows and nothing else is an open table:
t = { * K_t => V_t, * K_r => V_r }
One typed table row plus one trailing typed catch-all rest row. Entries are routed by wire major type, peeked before any deserializer runs: the typed row claims exactly its key's single statically-known major, and the catch-all sees only the complement. Once the major routes an entry, that row's K/V decide — a key or value its row refuses is a hard parse error, never a fall-through to the other row. (That is what makes the shape compose with a backtracking type choice: the error fails the enclosing arm, which rewinds.)
It lowers to a struct with two containers — entries (the typed row) and rest (the catch-all), each @name-renameable, each carrying its own @duplicates policy and its own encoding sidecars. The loose * / 0* rows are not new() arguments and default empty. A restricted row is instead a checked carrier input, except that the shipped typed-+ constructor keeps its new(first_key, first_value) ABI.
Spelling the typed row + (or 1*) makes it a NonEmpty open table:
t = { + K_t => V_t, * K_r => V_r }
The minimum of 1 counts typed entries — a map of purely captured entries is not a non-empty table — and its public typed field is the corresponding NonEmptyMap / NonEmptyPairMap carrier. new(first_key, first_value) enters that carrier directly; CBOR stages into the loose row container then uses its TryFrom door, while JSON stages then calls new, so every entrance shares the same refusal and the public row cannot be cleared or have its final default-map entry removed. The catch-all has its own equivalent +/1* NonEmpty carrier and does not borrow the typed count. * and 0* remain loose; every other marker (?, n*m, *n, n*, and omitted exact-once) is a checked BoundedMap/BoundedPairMap window for that particular row. A bounded typed row stays flattened on the owner class, while a bounded catch-all crosses as a checked wrapper; WIT/component despecialize either to a fallible list of pairs then restore the carrier. JSON Schema intentionally has no object-wide property bound because it cannot count one row independently.
K_t's major must be statically knowable, by one of two routes:
- if the key's alias chain carries a
@custom_serialize/@custom_deserializepair, its@custom_wire_majordeclaration is the answer — and is required; - otherwise the key's type must admit exactly one major. Primitives, primitive-bodied aliases, raw-bytes markers and aliases of them,
.size-constrained bytes and tagged types qualify. A plain extern,Int, a multi-major union,anyand an optionally-tagged type admit more than one and are refused, with the message naming both routes.
A catch-all whose admissible majors the typed row already exhausts is refused too — it could never capture an entry.
Its JSON face is one flattened object holding both rows, read back typed-first — worth reading before choosing a K_t, because a key type admitting every member name leaves the catch-all unreachable through JSON. Where that is decidable the tool refuses rather than ships it: a K_t transparently resolving to String (bare text, or an alias of it) is a graceful rejection under --json-serde-derives/--json-schema-export, and a supported shape without them.
Not supported (each a graceful rejection naming the remedy): more than two rows, or two rows mixed with fixed keys; an inline anonymous spelling (f: { * k1 => v1, * k2 => v2 } — give it its own named rule); any on the typed row (it would claim all eight majors, leaving the catch-all nothing — any belongs on the catch-all); a null-admitting key on either row; and @ignore on either row.
Overriding the wire of a type this crate does not define
A type-level pair is honored wherever the alias resolves — and an alias may resolve to a type this crate does not define. Writing the pair on an alias whose body references a _CDDL_CODEGEN_RAW_BYTES_TYPE_ or _CDDL_CODEGEN_EXTERN_TYPE_ rule is therefore how you say "this rule is that type, written differently on the wire":
policy_id = _CDDL_CODEGEN_RAW_BYTES_TYPE_
; the CIP-25 v1 rendering: the same 28-byte policy id, written as hex text
policy_id_v1 = policy_id ; @custom_serialize write_policy_hex @custom_deserialize read_policy_hex
t = { * policy_id_v1 => uint }
mints no Rust type at all — not a wrapper, and not a pub type either — so a value declared through policy_id_v1 is the hand-written PolicyId, in memory and across every API, and that is how it is spelled. Meanwhile the pair owns the wire at every position the alias reaches (a record field, both halves of a table, a rest row's key domain). The division of labor is the point: the CDDL body states what the thing is (the semantic identity, and the Rust type you get), and the pair states how it is written here. Two spellings of one wire are then two aliases of one rule.
This is why the pair is refused on the marker rule itself: that rule names a type whose own impls own its wire. The alias is the spelling that leaves that type alone.
Which signature the codec gets follows the encoding-variable rules above, applied to the type the alias resolves to:
- a raw-bytes marker demands exactly one
StringEncoding— the same slot a string-framed custom wire (hex text, bech32, base64) needs — so this flavor infers its signature and needs no declaration; - a plain extern is self-carrying and demands nothing, so under
--preserve-encodingsits alias must declare its wire with@custom_encodings(an undeclared pair there is a graceful rejection, not a silent normalization); - without
--preserve-encodingsno encoding variable exists at all and both flavors get the plain signatures.
Two further consequences worth knowing before you reach for this:
- The two positions rule applies unchanged: a record field passes its stored encoding by reference and a table entry's by value, so an alias reached from both needs two codec functions. Sharing one is a compile error in the generated crate (an
E0308naming&StringEncodingagainstStringEncoding), not a silently different wire. - Such an alias does not travel across the extern-interface seam: like any transparent alias carrying
@custom_serialize, it is recorded as an; unexported:row, because a consumer given the plain alias would emit default wire logic. The marker rule itself exports normally.
For executed vectors see tests/alias-of-marker-e2e (the raw-bytes flavor, both table positions plus a record field, byte-exact under --preserve-encodings --canonical-form) and tests/custom-encodings-e2e (the extern flavor, with its declaration).
Table key and value positions
A type-level pair is honored wherever the alias resolves, which includes both halves of a table:
hex_table_str = bytes ; @custom_serialize write_hex_table_string @custom_deserialize read_hex_table_string
custom_table_positions = [
keyed: { * hex_table_str => uint },
valued: { * uint => hex_table_str },
]
Here the key domain and the value range each go through the custom pair, so a bytes key can be written as hex text on the wire while staying decoded bytes in memory. Only the type-level spelling reaches these positions — a table row has no field to hang a field-level directive on (see the un-honored positions below).
An open struct-map rest row ({ 1: uint, * k => v }) is the same two positions, honored the same way in both directions. A custom key codec is in fact what decides how the row reads its keys: a row whose domain carries one is read by that codec rather than rebuilt from the decode loop's own key dispatch, which is what keeps the row's write and read halves symmetric.
Two details specific to a table under --preserve-encodings=true:
- The per-entry encoding is looked up out of the table's sidecar map and passed by value (
StringEncoding), where a record field passes its stored encoding by reference (&StringEncoding). The two positions therefore need separate functions if you want to share one codec. - Both sidecars (
<field>_key_encodingsand<field>_value_encodings) areBTreeMaps keyed by the decoded key. A custom key deserializer's returned encoding is filed under the value it decoded to, not under the bytes it consumed, and the value sidecar is likewise keyed by the entry's key rather than by the value.
Under --canonical-form=true a table's keys are additionally serialized into a scratch buffer to sort by their encoded bytes, so a custom key serializer is called twice per entry (once for the sort key, once for the write) and must be deterministic. A custom value serializer is only on the write path.
A whole-table codec pair
A complete pair on a named homogeneous table rule owns the table's entire CBOR item:
custom_table = { * text => uint } ; @custom_serialize write_custom_table @custom_deserialize read_custom_table
holder = [table: custom_table]
The rule becomes a nominal map wrapper (CustomTable), while retaining the normal new, From,
and getter construction surface. Its generated Serialize/Deserialize shells and every embedded
reference call the two named functions, so the hand codec may deliberately use a non-map wire form.
It receives the wrapper itself, not table entry encodings. Under --preserve-encodings, the codec is
self-carrying: it must accept only bytes it can reproduce from the wrapper (including entry order and
any CBOR widths it chooses not to store). Under --canonical-form, its writer additionally receives
the trailing force_canonical: bool argument.
This applies to the loose, @duplicates preserve, non-empty, and generic homogeneous table forms.
A lone half remains rejected: one direction would retain the generated table codec while the other
would route through hand code. The row-entry slot remains rejected too; it names neither the whole
table type nor a table field.
Positions that are rejected
The pair parses in the positions below but cannot be honored there, so each one is a graceful generation error naming the spelling that works. The common cause is that the pair is a type-level override: it replaces the codec of the rust type the rule resolves to. Some positions delete the node the override is keyed on or mint a type whose codec it cannot replace; explicit and tagged wrappers are also refused because this delivery audits only the implicit homogeneous-table owner, not their broader wrapper contracts.
- On a
_CDDL_CODEGEN_EXTERN_TYPE_or_CDDL_CODEGEN_RAW_BYTES_TYPE_rule. Either marker names a type this crate does not define, so that type owns its own (de)serialization impls and there is nothing here for the pair to displace. Two spellings work instead, and which one you want depends on whether the Rust type is to change: give the rule a real CDDL body and put the pair there (that also keeps the wire type stated in the spec), or keep this rule as the marker and put the pair on an alias of it (policy_id_v1 = policy_id ; @custom_serialize …), which keeps the marker's Rust type and overrides only how it is written — see Overriding the wire of a type this crate does not define. - On a table row, an open-map rest row, or an open-array rest tail entry — a trailing comment on the occurrence entry itself. What a row slot carries is row-scoped (an open-map rest row takes
@nameand@duplicates, plus@ignoreonly when loose; an anonymous inline table's row takes@duplicates; a NAMED table's row takes nothing — its policy lives at the rule slot), and the pair names a type the row does not declare. Put it on the row's key, value, or element rule instead. - Together with explicit
@newtype. The supported/audited wrapper owner is only the implicit, untagged homogeneous-table map made by a complete pair. This delivery does not define custom-codec behavior for an explicit wrapper — including tags, ranges, set policy, preserve encodings, or Rust/WASM/JSON/WIT faces — so it is refused rather than extended by analogy. Use the plain alias spelling, or declare the type_CDDL_CODEGEN_EXTERN_TYPE_and hand-write it in full. - On a rule that mints a TAGGED wrapper without the directive — a tag-head rule (
foo = #6.42(uint)) and the tag-258 set idiom (foo = #6.258([* uint]) / [* uint], which nominalizes into a set wrapper). The supported implicit homogeneous-table owner does not define this tag framing or its set, encoding-preservation, and cross-face contracts, so either half is refused. Put the pair on the alias the tag rule wraps (inner = uint ; @custom_serialize …besidefoo = #6.42(inner)), which keeps the tag framing generated and overrides only the payload — see Reaching an annotated alias through another rule below. Or, to own the whole wire yourself, declare the rule_CDDL_CODEGEN_EXTERN_TYPE_and hand-write the type, or give it a body that resolves to a transparent alias and write the wire framing (the tag included) in your own codec. - On a rule that binds a generic instantiation —
foo = base<uint>, and the named-binding-to-a-set-nominal form of the same shape. The type is built during generic resolution from the definition's config, so a pair written on the binding never reaches generation and both directions keep the definition's generated codec. For a generic definition that mints a record, put a complete pair on that definition: each concrete record then gets thin delegation. Other generic-definition kinds have no concrete record codec slot; give the rule a CDDL body of its own, or declare it_CDDL_CODEGEN_EXTERN_TYPE_. - On an enum rule — a type choice (
a / b), a group choice ({ … } // { … }), or a fixed-value C-style enum (0 / 1) — in every spelling, one half or both. The enum's serialize side is generated unconditionally while the deserialize call sites do route through the custom reader: the same read-one-format/write-another asymmetry as@newtype. Put the pair on the rule of the variant type that needs the custom format.
The next three positions honor the complete pair — a lone half is what each refuses. The two halves are lifted independently, so a single half would leave one direction routing the named function while the opposite direction keeps a generated codec: one CDDL name reading one wire format and writing another (or, on a record rule, a crate that does not compile). What differs per position is which codec survives and where the divergence shows:
- A single half on a named record rule.
@custom_serializealone emits noSerializeimpl and never calls the named function, so the generated crate does not compile;@custom_deserializealone keeps the type's own generatedDeserializeimpl while rewriting every embed site, soFoo::from_cbor_bytesand a field of typeFoodecode the same bytes differently — a divergence that also travels to consumers, since such a rule projects opaquely across the extern-interface seam. Move the pair to the field, or write both halves on the named record rule. - A single half on a transparent alias rule. The alias twin of the case above, and the more insidious shape of it: an alias's lone half compiles and routes — the rewritten direction is every embed site that reaches the alias, and the surviving codec is the aliased type's. Write both halves (
inner = uint ; @custom_serialize <fn> @custom_deserialize <fn>), or drop the directive. A rule that inherits its wire facts from the alias it renames is not a second offence: the refusal names the rule the half was written on. - A single half at a field/member slot. The field twin — a lone half at a field also compiles and routes, the surviving codec being the field type's, so the field writes bytes it cannot read back, in array- and map-rep records alike. Write both halves on the entry (
; @custom_serialize <fn> @custom_deserialize <fn>), or move the pair to the member's type rule if the format belongs to the type. A plain group rule's trailing comment binds to that group's last member, so it is this refusal that fires there, naming the entry the comment reached rather than the rule it was written after. - On the member of a SINGLE-ENTRY group-choice arm — an arm whose one entry ends its line with
; @custom_serialize …, in either representation, one half or both. This looks like the field slot above and is not one: an arm holding exactly one entry registers no record at all — the entry's type goes straight into the enum variant — so there is no field for the pair to ride and the variant's codec is generated by the enum. Name the member's type as its own rule and put the complete pair there (inner = bytes ; @custom_serialize <fn> @custom_deserialize <fn>, then// f: inner); that does route both directions at the arm. A multi-entry arm does mint a record and honors the pair exactly as an ordinary field, which is the difference the message names. - On a named collection rule —
items = [* uint] ; @custom_serialize …, the array sibling of the table rule above and refused for the same reason: a named collection lowers to a transparent collection typedef rather than to a record with generated trait impls, so the pair reaches neither the collection's standalone codec nor a holder's field call sites, and any presence is refused, one half or both. Every flavor that lowers this way is covered — the loose[* t], the non-empty[+ t], a bounded[3*5 t], and both@duplicatesspellings. Put the pair on the element rule if the element wire is what changes (t = bytes ; @custom_serialize …, thenitems = [* t]), or declareitemsas_CDDL_CODEGEN_EXTERN_TYPE_and hand-write the type in full to own the whole collection's wire.
The @custom_encodings declaration is refused in three further places, each because it would otherwise be read and then dropped:
- Without both halves of the pair, in the same position — see the rule above. This covers every slot the declaration can be written in: a rule, a field, and the row-entry slots the pair itself is refused in.
- On a rule that mints a struct — including the accepted complete pair on a record rule and the implicit wrapper owning a complete homogeneous-table pair. A struct carries its encoding metadata inside itself (its
encodingsmember) and hands no encoding tuple across the call, so there is nothing for a declaration to describe. Put it beside a field's pair, or on a transparent alias rule's pair. - Everywhere the pair is already refused (the list above), where the pair's own rejection fires and names the spelling that works.
@custom_wire_major is refused in the same three places, plus one of its own: on a rule nothing consumes it from — the declared major is read only where a transparent alias keys an open table's typed row or proves a variable middle open-array boundary, so a declaration reaching neither states a fact about a wire no reader needs.
Reaching an annotated alias through another rule
An annotated alias has one wire form however it is reached. Every context that reaches it — a plain member, a rule that wraps it, or another name for it — routes through the pair in both directions, and owns only the framing it declares itself:
inner = uint ; @custom_serialize my_ser @custom_deserialize my_deser
tagged = #6.42(inner) ; wraps it in a tag head
payload = [f: bytes .cbor inner] ; wraps it in a CBOR byte string
renamed = inner ; another name for it
via_renamed = [r: renamed]
direct = [d: inner]
Tagged writes tag 42 and then calls my_ser; it reads tag 42 and then calls my_deser. payload.f writes the byte string whose contents my_ser produced, and reads my_deser from a reader over those contents. via_renamed.r and direct.d call the same pair with no framing at all. The wrapping rules add exactly the framing their own rule declares, and nothing else. This is why the tagged-wrapper rejection above names the tag spelling as the remedy for putting the pair on the tag rule itself. (A bytes .cbor inner rule body is a wrapper too — body = bytes .cbor inner mints a struct whose codec writes the byte string around my_ser's output — so it behaves exactly like Tagged here rather than like renamed.)
The pair replaces the codec of the type the alias resolves to, at every position that reaches the alias — a member, an arm, a wrapping rule. It does not give the alias a type of its own, and the alias therefore emits no pub type at all (rust and wasm alike): inner above mints no Inner, and members declared through it are typed u64.
That absence is the point. A pub type Inner = u64; would hand the CDDL name a standalone Inner::to_cbor_bytes() / Inner::from_cbor_bytes() that are u64's built-in codec, not my_ser / my_deser — while every embed site of inner routes the pair. One CDDL name would have two wire forms, selected by whether you called the standalone entry point or went through a holder. Nothing per-alias is emitted for the pair to displace there, so the name is removed rather than fixed.
What the CDDL name still carries is everything about the wire: the pair, its @custom_encodings / @custom_wire_major declarations, the variant and structural class names derived from it ([* inner]'s wasm wrapper is still InnerList), and the ; unexported: row it projects across the extern-interface seam. If you need a Rust type of your own whose standalone codec IS the custom wire, declare the rule _CDDL_CODEGEN_EXTERN_TYPE_ and hand-write the type, or give the pair a wrapping rule to sit under and use that name.
If a consumer's hand-written code names the alias as a type (let p: PolicyIdV1 = …, a function signature, a use), regenerating produces a compile error naming the vanished typedef. The fix is to spell the type the alias resolves to (PolicyId, u64, …) — the two named one type all along, since the suppressed alias was transparent. No wire bytes change, in either direction, under any profile.
The declared spelling follows the same ownership line, as output_format states in full: Tagged wraps the resolved u64, and payload.f is typed u64 — the .cbor there belongs to the member's own type expression, so the alias still denotes the value inside the byte string even though it names no type to spell it with.
renamed inherits inner's wire facts — the pair and any @custom_encodings/@custom_wire_major written beside it — because a rule that is just another name for a type is a name for its wire too. A rule that writes its own pair inherits nothing: its declaration describes its whole wire.
Under --preserve-encodings the split follows the same line: the framing a wrapping rule declares — the tag's Sz, or the byte string's own length encoding — stays a generated-code-owned slot on the owner's encoding struct, and the payload's encoding variables are the ones that cross the call — declared by the alias's @custom_encodings if it carries one, inferred from the aliased type otherwise.
For more examples see tests/custom_serialization (used in the core and core_no_wasm tests), tests/custom_serialization_preserve (used in the preserve-encodings test, including the table key/value positions above) and tests/custom_serialization_canonical (used in the custom-serialize-canonical-e2e test, for the force_canonical signatures).
@doc
This can be placed at field-level, struct-level, variant-level or rule-level (including plain alias rules and @newtype rules) to specify a comment to be placed as a rust doc-comment.
docs = [
foo: text, ; @doc this is a field-level comment
bar: uint, ; @doc bar is a u64
] ; @doc struct documentation here
docs_groupchoice = [
; @name first @doc comment-about-first
0, uint //
; @doc comments about second @name second
text
] ; @doc type-level comment
Will generate:
/// struct documentation here
#[derive(Clone, Debug)]
pub struct Docs {
/// this is a field-level comment
pub foo: String,
/// bar is a u64
pub bar: u64,
}
impl Docs {
/// * `foo` - this is a field-level comment
/// * `bar` - bar is a u64
pub fn new(foo: String, bar: u64) -> Self {
Self { foo, bar }
}
}
/// type-level comment
#[derive(Clone, Debug)]
pub enum DocsGroupchoice {
/// comment-about-first
First(u64),
/// comments about second
Second(String),
}
On an alias rule the doc lands on the emitted pub type, composed with any note the generator
synthesizes itself (e.g. the non-empty [+ T] bound note) — user doc first, mechanical note after:
positive_coin = coin ; @doc Does not enforce > 0: plain u64 alias for API convenience.
/// Does not enforce > 0: plain u64 alias for API convenience.
pub type PositiveCoin = u64;
A dataless (C-style) enum — a type choice whose arms are all fixed values — carries docs the same
way. On any type-choice rule the rule-level doc slot is the last arm's trailing comment, so a single
@doc there documents both the enum and that arm's variant:
network = 0 ; @name mainnet @doc the production network
/ 1 ; @name testnet @doc the test network
/// the test network
#[derive(Copy, Eq, PartialEq, Ord, PartialOrd, Clone, Debug)]
pub enum Network {
/// the production network
Mainnet,
/// the test network
Testnet,
}
Due to the comment dsl parsing this doc comment cannot contain the character @.
Known limitation: on a type choice of fixed values only (a dataless enum, e.g. foo = 0 / 1),
per-variant @doc is currently not emitted; it works on data-carrying variants (e.g. uint / tstr).
Plain groups nothing splices
A plain group rule (foo = (a: uint, b: uint)) becomes a Rust type only when some rule splices
it — holder = [foo], holder = {foo}. A group no rule splices emits neither a struct nor a field,
so every rule-position directive written on it is inert, and writing one is a graceful generation
error naming each directive it found:
foo = (a: uint, b: uint) ; @custom_json
holder = [z: uint]
@custom_json on 'foo': the plain group 'foo' is never spliced into any rule, so it materializes no rust type and no fields …
Splice the group into a rule that materializes it, or remove the directive. Once spliced, the same
slot is where a rule-position directive on a group is read: @custom_json and @used_as_key land
on the record struct the splice mints, @doc documents it, @name renames the last field, and a
directive that is invalid on a record (@copy, @extern_companions, …) gets its own specific
rejection naming its own remedy.
Two directives keep their own messages instead of joining this one, so a single misplacement is
reported once: @name (its long-standing "does not rename a top-level rule or group" rejection) and
@no_json_schema_export (whose own rejection already names this shape). @rust_name is left alone —
it is honored on a rule in a _CDDL_CODEGEN_EXTERN_DEPS_DIR_ scope, spliced or not.
CDDL_CODEGEN_EXTERN_TYPE
While not as a comment, this allows you to compose in hand-written structs into a cddl spec.
foo = _CDDL_CODEGEN_EXTERN_TYPE_
bar = [
x: uint,
y: foo,
]
This will treat Foo as a type that will exist and that has implemented the Serialize and Deserialize traits, so the (de)serialization logic in Bar here will call Foo::serialize() and Foo::deserialize().
Under --json-serde-derives the type must additionally implement serde::Serialize/serde::Deserialize, and under --json-schema-export also schemars::JsonSchema, since the generated code delegates its JSON representation to the user type (and --json-schema-export emits a schema-registration row for each such extern). Whenever the extern is embedded in a generated struct, that struct's derive(JsonSchema) already forces the impl, so this is no new burden in the common case. The generic BASE of an extern generic (Foo in foo<T>) gets no schema row — a bare Foo names no concrete type — while each concrete instance (FooU64 from foo<uint>) and every plain extern does.
That impl's schema_name() must be unique within the crate, and for a GENERIC extern it must vary with the parameters:
impl<T: schemars::JsonSchema> schemars::JsonSchema for Foo<T> {
fn schema_name() -> std::borrow::Cow<'static, str> {
format!("Foo_{}", T::schema_name()).into()
}
// …
}
The reason is that schema_id() defaults to schema_name(), and schemars keys its whole identity decision on the id: a hand-written impl returning a constant name makes Foo<u64> and Foo<String> the same type, so the document emits one definition and every reference to the other instantiation silently resolves to it. The minimal correct impl — implement the two required members and nothing else — is exactly the spelling that hits this. Violating it is a hard failure of the json-gen run (cddl-codegen --json-schema-export: two distinct Rust types both publish the JSON schema name "Foo": …), naming both offenders; see --json-schema-export for the second message, which fires when a registered type is pushed onto an order-dependent <name>2 by a type that has no row of its own.
This can also be useful when you have a spec that is either very awkward to use (so you hand-write or hand-modify after generation) in some type so you don't generate those types and instead manually merge those hand-written/hand-modified structs back in to the code afterwards. This saves you from having to manually remove all code that is generated regarding Foo first before merging in your own.
This also works with generics e.g. you can refer to foo<T>. As with other generics this will create a pub type FooT = Foo<T>; definition in rust to work with wasm-bindgen's restrictions (no generics) as on the wasm side there will be references to a FooT in wasm. The wasm type definition is not emitted as that will be implementation-dependent. For an example see extern_generic in the core unit test.
Where to put your definition: in a hand-written module re-exported at the crate root — the tool emits pub use crate::Foo; glue into the declaring scope's generated module so every generated reference resolves back to it. See Output format § Generated crate roots for the full contract (rust and wasm sides).
@raw_bytes_flavor
A bare tag valid only on a _CDDL_CODEGEN_EXTERN_TYPE_ generic. It lets one extern generic reference a second, differently-bounded wrapper for the instances whose element is a raw-bytes type:
ext_set<T> = _CDDL_CODEGEN_EXTERN_TYPE_ ; @raw_bytes_flavor
When an instance's argument resolves to a _CDDL_CODEGEN_RAW_BYTES_TYPE_ (e.g. x = ext_set<pub_key>), the monomorphized alias references the convention-named <ExternName>RawBytes flavor instead of the plain name — pub type ExtSetPubKey = ExtSetRawBytes<PubKey>; — and the crate-root re-export glue emits pub use crate::ExtSetRawBytes; alongside the base pub use crate::ExtSet;. Every instance whose arguments are all non-raw-bytes keeps the plain Foo<T> name.
This exists because a raw-bytes type implements RawBytesEncoding (the caller frames its CBOR bytes) rather than the CBOR Serialize/Deserialize traits, and a single wrapper type cannot serve both element contracts at once — impl<T: Serialize> Serialize for Foo<T> and impl<T: RawBytesEncoding> Serialize for Foo<T> are conflicting blanket impls. The <ExternName>RawBytes flavor is a second, separately-bounded user-owned wrapper for exactly the raw-bytes instances.
The tag is opt-in and never automatic: a consumer whose single wrapper is bound solely on RawBytesEncoding already compiles today under the plain name, so flavoring the plain name whenever the two markers compose would silently break that working output. The tag takes no name argument — the wrapper is user-owned, so any preferred name is one pub type <ExternName>RawBytes<T> = Whatever<T>; alias away.
Generic is required. Using it on any rule other than a _CDDL_CODEGEN_EXTERN_TYPE_ definition is a hard error, and so is using it on an extern that declares no generic parameters (foo = _CDDL_CODEGEN_EXTERN_TYPE_ ; @raw_bytes_flavor): the tag flavors generic instances, so a base with no parameters has no instances to flavor and the tag could only sit there inert. Declare the parameters or drop the tag.
Seam projection. Unlike @copy, this tag does not travel the extern-interface export. The export renders every rule body as a bare marker and drops generic parameters, so a flavored base projects as the param-less ext_set = _CDDL_CODEGEN_EXTERN_TYPE_ — a form the tag cannot be honored on, and the exact spelling the hard error above refuses. A --extern-import consumer therefore sees the base without the tag, which costs it nothing: it cannot instantiate a param-less base either way. Flavoring across a crate boundary means declaring the extern in the consumer's own spec.
@copy
A bare tag valid only on a _CDDL_CODEGEN_EXTERN_TYPE_ or _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule. It declares that the externally-defined rust type derives Copy:
hash = _CDDL_CODEGEN_RAW_BYTES_TYPE_ ; @copy
Without it, the generator emits a defensive .clone() at every boundary that moves the value — map-key deserialize loops (table.insert(key.clone(), …)), wasm getters (self.0.field.clone().into()), optional getters (self.0.field.clone().map(Into::into)), list indexed getters (self.0[index].clone().into()), and enum-variant accessors ((hash).clone().into()). Downstream clippy::clone_on_copy then flags each. With @copy, those clones are dropped: the value copies instead (table.insert(key, …), self.0.field.into(), self.0.field.map(Into::into), self.0[index].into(), (*hash).into()).
Only the rust face changes. The type's wasm face is a distinct #[wasm_bindgen] wrapper that is not Copy, so the wasm→rust boundary keeps its clone (constructor/setter params) — @copy never drops a clone that would move out of a borrowed wasm value.
Honesty assertion. The declaring crate emits a compile-time Copy assertion for each @copy type (a _assert_copy::<T>() bound-carrier in generated/extern_interface_check.rs), so a false @copy — a type that does not actually derive Copy — fails that crate's own build with a named error, never a distant consumer's.
Seam projection. @copy travels the extern-interface export (extern-interface/<dep>/**), so a --extern-import consumer that imports the type inherits its Copy-ness and drops the same boundary clones. It can, because it describes the base type — the export's param-less rendering carries it faithfully. (@raw_bytes_flavor describes a generic instance instead, which is why it does not travel; see its section above.)
Using it on any rule other than a _CDDL_CODEGEN_EXTERN_TYPE_ / _CDDL_CODEGEN_RAW_BYTES_TYPE_ definition (or at a field position) is a hard error.
@extern_companions
Valid only on a non-generic _CDDL_CODEGEN_EXTERN_TYPE_ or _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule
declared in this crate's own spec (not in a _CDDL_CODEGEN_EXTERN_DEPS_DIR_ scope). Both markers
name a type this crate does not define while the generator still mints that type's structural
companion classes, so both can collide with a sibling crate's class of the same name. The directive
declares that specific structural wasm companion classes of that type already exist in a sibling
wasm crate, so the generator references them instead of minting its own:
transaction_metadatum = _CDDL_CODEGEN_EXTERN_TYPE_ ; @extern_companions cml_chain_wasm=TransactionMetadatumList
; identical contract on the raw-bytes marker — a hash type whose `<Name>List` the sibling owns
pub_key = _CDDL_CODEGEN_RAW_BYTES_TYPE_ ; @extern_companions cml_chain_wasm=PublicKeyList
The generated wasm crate then emits use cml_chain_wasm::TransactionMetadatumList; and mints no local
class of that name. The argument is <use_path_prefix>=<Class>[,<Class>…] — one prefix (a rust path,
emitted verbatim as the use head) and a comma-separated list of class names, with no whitespace
anywhere in the argument.
The problem it solves
When a rule declares an extern or raw-bytes type, the generator still owns that type's structural
companions — the <Elem>List, Map<K>To<V> and their siblings it synthesizes for [* t] / {* k => v} shapes.
Each is a #[wasm_bindgen] class. If the sibling crate that defines the extern type also defines a
class of the same structural name (typically the canonical <Name>List), and both wasm crates link
into one cdylib, the link fails:
rust-lld: error: duplicate symbol: __wbg_transactionmetadatumlist_free
>>> defined in …/cml_cip25_wasm….rcgu.o
>>> defined in …/libcml_chain_wasm.rlib(…)
Renaming one side (via @rust_name on the extern rule) removes the collision but mints a second JS
class for one concept, with no type identity against the sibling's own API — a value obtained from
the sibling cannot be handed back to it. Referencing the sibling's class is the fix that keeps one
concept, one class.
The companion class contract
The referenced class is not checked by the tool — it is the consumer's own compile that checks
it, exactly like the extern marker itself: an absent or wrongly-shaped class fails the emitted use
(or the conversion at its use site) loudly and near. What the generated code requires of it:
-
It is public at
<prefix>::<Class>and is a#[wasm_bindgen]class (so JS sees one class, and so the ABI symbols it defines are the ones this crate no longer defines). -
It derives (or implements)
Clone— every boundary crossing clones before converting. -
From<Inner> for <Class>andFrom<<Class>> for Inner, plusAsRef<Inner>, whereInneris the rust core type of the shape the class stands for, over the same rust element type this crate uses (i.e. the sibling's own rust type, which this crate re-exports for the marker rule):Deferred class Inner<Elem>List([* elem])Vec<Elem>NonEmpty<Elem>List([+ elem])NonEmptyVec<Elem>Map<K>To<V>({* k => v})BTreeMap<K, V>, orOrderedHashMap<K, V>under--preserve-encodingsPairMap<K>To<V>({* k => v} ; @duplicates preserve)PairMap<K, V>NonEmptyMap<K>To<V>/NonEmptyPairMap<K>To<V>({+ …})NonEmptyMap<K, V>/NonEmptyPairMap<K, V>A
From<Vec<_>>is what a table'skeys()accessor uses (…collect::<Vec<_>>().into()): a cross-crate wrapper's tuple field is private, so the deferred class is never built by tuple-struct syntax. -
Its JS-visible method surface should match what the generator would have emitted (
new/len/get/addfor a list;new/len/insert/get/keysfor a map) — nothing in the generated rust depends on this, but JS callers of this crate's API do. In CML's runtime theimpl_wasm_list!macro produces exactly this shape.
Only listed classes defer
The class list is a filter, not a blanket opt-out. An unlisted structural companion of the same extern type still mints locally — which is what lets a crate borrow the family the sibling owns and keep the one it doesn't:
transaction_metadatum = _CDDL_CODEGEN_EXTERN_TYPE_ ; @extern_companions cml_chain_wasm=TransactionMetadatumList
; keys() returns the SIBLING's TransactionMetadatumList …
; … while PairMapTransactionMetadatumToTransactionMetadatum is minted here (the sibling's map class
; is hand-named something else, so there is no collision to avoid)
meta = {
1: uint,
* transaction_metadatum => transaction_metadatum ; @duplicates preserve
}
A wrapper defers only when every named constituent resolves to the declaring extern type. A mixed
shape ({* transaction_metadatum => local_thing}) is not "of" that type and mints locally even if its
structural name is listed.
Manifest and flag notes
- The wasm crate's dependency on the sibling is yours to declare. The tool adds nothing to
wasm/Cargo.tomlfor this directive (pass--wasm-dep=<pkg>=<path>if you want the tool to assert the entry). This mirrors the extern re-export contract: the directive states a fact about your workspace, it does not create one. - Inert without
--wasm. The classes it governs are a wasm-boundary concern; a rust-only build mints none, so the directive changes nothing. One spec, many flag sets. - The prefix is emitted as written, with one remap. If its leading component names a declared
extern dependency,
--extern-wasm-craterewrites that component to the dependency's wasm crate — the same rule every cross-crate import obeys. It only bites when you spell a dependency's rust crate name here, where the remap yields the wasm crate you meant anyway; spell the wasm crate directly and nothing rewrites. - Every structural family defers, this one included.
<Elem>OrderedSet/NonEmpty<Elem>OrderedSet(the@duplicates rejectuniqueness twins) consult the same seam as loose/restricted lists, loose/restricted maps and the@duplicates preservepair maps, so listing such a name works exactly as listing any other does.
When to use this vs the dependency-keyed flags
| Situation | Mechanism |
|---|---|
The type is declared in a _CDDL_CODEGEN_EXTERN_DEPS_DIR_/<dep>/ scope (or via --extern-import) and the dep's own generation minted the wrapper | --extern-wrapper-index=<dep>=<dep>/wasm/src/generated/collections.rs — the dep's committed index is consulted, so the tool knows the class exists |
| Same, and the whole workspace regenerates together | --workspace-dep=<dep> — defers unconditionally and records a request sidecar so the dep mints what you need |
| The type is marked extern or raw-bytes in this crate's spec (a one-type re-export of a sibling's type), and/or the sibling's class is hand-written so no generated index lists it | @extern_companions |
The first two key on the constituents' owning dependency, which a locally-declared marker does
not have; and an index can only list what generation minted. Using @extern_companions on a
dep-scoped rule is a hard error naming both flags, so the two never compete for one decision.
Rejected positions
Using it anywhere other than a locally-scoped non-generic _CDDL_CODEGEN_EXTERN_TYPE_ /
_CDDL_CODEGEN_RAW_BYTES_TYPE_ rule is a
hard error: on a
rule this crate generates (a record, a type/group choice, a collection) — such a rule owns the
companions it mints, so there is nothing to borrow — at a
field/member position, on a non-last arm of a multi-choice type rule, and on a dep-scoped extern.
A generic extern base (foo<T> = _CDDL_CODEGEN_EXTERN_TYPE_) is rejected too: every companion
class is named from the ident at the use site, so an instance i = foo<uint> used as [* i] mints
IList and never FooList — a deferral declared on the base is looked up under a name nothing asks
for. Declare the concrete shape as its own non-generic extern rule and put the deferral there. (The
raw-bytes marker needs no such rule of its own: a generic _CDDL_CODEGEN_RAW_BYTES_TYPE_ rule is
rejected outright, directive or not — see its section below.) A
malformed argument (missing, no =, a non-path prefix, an empty or non-identifier class name) is a
loud parse-time failure rather than a silent drop — a silently dropped declaration re-mints the very
classes it exists to suppress, and the only symptom would be a duplicate symbol in a different
crate's link. Finally, if a listed class name is also defined by a rule in this crate, generation
fails naming both: the use and the rule would claim one identifier.
CDDL_CODEGEN_RAW_BYTES_TYPE
Allows encoding as bytes but imposing hand-written constraints defined elsewhere.
foo = _CDDL_CODEGEN_RAW_BYTES_TYPE_
bar = [
foo,
]
This will treat foo as some external type called Foo. This type must implement the exported (in serialization.rs) trait RawBytesEncoding.
The contract under the json flags is the same as _CDDL_CODEGEN_EXTERN_TYPE_'s above: serde::Serialize/serde::Deserialize under --json-serde-derives, plus schemars::JsonSchema under --json-schema-export, since the generated code delegates its JSON representation to the user type — including the obligation that the impl's schema_name() be unique within the crate, and that a GENERIC raw-bytes type's impl vary the name with its parameters (schema_id() defaults to schema_name(), so a constant name silently merges every instantiation into one published definition; the json-gen run fails naming both offenders). If that contract ever changes, both sections change together.
One position asks more of Serialize than "be implemented": a JSON object MEMBER NAME. A raw-bytes type keying a table ({* foo => v}), an open table's typed row or a typed rest row renders through serde_json's map-key serializer, which admits a string, an integer, a bool and a unit variant and refuses everything else — so a Serialize writing a byte SEQUENCE (the natural derive over a byte array, or a Vec<u8> passthrough) makes to_json fail at runtime on every such map, naming the key. Nothing checks this at compile time, and the CBOR face is unaffected, so a spec can pass every build and fail on its first JSON write. What satisfies it is what a hand-written raw-bytes type is expected to carry anyway: a string image — lowercase hex, bech32, base64 — with the matching Deserialize reading it back. The same obligation reaches a @newtype wrapper over bytes and any extern used in those positions.
This can be useful for example when working with cryptographic primitives e.g. a hash or pubkey, as it allows users to have those crypto structs be from a crypto library then they only need to implement the trait for them and they will be able to be directly used without needing any useless generated wrapper struct for the in between.
The wasm companion classes minted for the shapes it appears in ([* foo], {* foo => …}, …) are the generator's, exactly as for an extern type — so when a sibling wasm crate already publishes one of them, @extern_companions defers to it here under the identical contract.
Placement follows the same contract as _CDDL_CODEGEN_EXTERN_TYPE_: define the type in a hand-written module and re-export it at the crate root — the tool emits pub use crate::Foo; glue into the declaring scope's generated module (this matters especially when the rule is referenced only through pub type aliases, which have no other way to resolve). The wasm crate mirrors this: define the #[wasm_bindgen] wrapper in a hand-written wasm-crate module and re-export it at the wasm crate root. See Output format § Generated crate roots.